mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
fix(cli): close the remaining OpenTUI parity gaps against ink (#12309)
The OpenTUI renderer is meant to stand in for ink, so a divergence is a defect in the port rather than a difference of taste. This sweep drives both renderers through the same scripted sessions and closes what the frame-by-frame comparison surfaced. The ink theme was never applied at all, so ui.theme, /theme and the light/dark probe were inert here; the session list behind /resume and /delete was one hand-rolled line per row with no checkbox column and no preview, so a bulk delete could only ever remove the row under the cursor; several dialogs still read the cursor and the text buffer of the previous render, which loses keys whenever one stdin read carries more than one; tool cards dropped their inline arguments, folded the status glyph into the title and ignored the settings that collapse them; and a few settings had no read site in this renderer at all. ink's own behaviour is untouched — the ink-side edits only export what the port now shares instead of duplicating.
This commit is contained in:
1 parent
0fa0c54289
commit
49dfc87906
80 files changed
+7965
-1580
No files matched your search
@@ -290,7 +290,9 @@ be relinquished rather than replaced is set to its auto value instead of being
|
||||
removed.
|
||||
|
||||
Sites that only ever set a prop, or only ever remove it, are unaffected and
|
||||
were surveyed rather than changed. No renderer-level test was added: the
|
||||
were surveyed rather than changed — a judgement that did not hold at the tool
|
||||
card's header, where the collapsed branch sets a width the expanded one never
|
||||
sets; Decision 50 corrects it. No renderer-level test was added: the
|
||||
behaviour lives in the reconciler, and reproducing it needs a real render
|
||||
surface, which the pty matrix already covers.
|
||||
|
||||
@@ -1767,20 +1769,621 @@ two sanitising spans all turn on what arrives beside a key, or on which code poi
|
||||
caret happens to sit on, and no scenario produces either. Like the five items of Decision
|
||||
39, these four rest on the unit suites.
|
||||
|
||||
## Decision 41 — the cursor is read from a ref, and a list wraps where ink's does
|
||||
|
||||
Two classes of defect this round were the same shape wearing different clothes: a
|
||||
handler answering from the value the last render closed over, and a list stopping at
|
||||
its ends where ink's continues round.
|
||||
|
||||
The first is Decision 33's stale-cursor class, which that decision closed on two
|
||||
handlers and left open everywhere else. It is now closed as a class. A twenty-eight
|
||||
line hook holds the pattern — a state value beside a ref mirror re-synced during
|
||||
render, a setter stable across renders that writes the ref synchronously, and
|
||||
handlers reading the ref — and the shared select hook returns the ref beside the
|
||||
index so every dialog that mounts it reads the same way. Six dialogs' wheel
|
||||
handlers read it too. Where the state is not a cursor, the same mirror was written
|
||||
inline beside the value it shadows: a checked set, two tab indices, and the
|
||||
authentication wizard's main and sub menus, each with its own writers. The class is
|
||||
closed for cursors and for those; the view and text state beside them is not, and
|
||||
is recorded under Follow-ups.
|
||||
|
||||
The second is navigation. ink's selection list steps modulo the row count, so down
|
||||
past the last row lands on the first. This port clamped. The helper that follows
|
||||
ink already existed and was used in one dialog; it is now used at twelve call sites
|
||||
across five dialog files, and the local clamping copy beside it was deleted. Its
|
||||
signature widened from the shared list-item shape to an untyped readonly array so a
|
||||
dialog whose rows are not that shape can use it too, with the disabled test done on
|
||||
the row it casts. One dialog keeps clamping on three of its four steps, because
|
||||
ink clamps there; it wraps on the fourth, which ink builds as a radio list. Its own
|
||||
comment says which is which, so the mix reads as a decision rather than an
|
||||
oversight.
|
||||
|
||||
Verified on frames, not only by test: a scenario boots in the last approval mode so
|
||||
the list's cursor starts at its end, and one down keystroke must land on the first
|
||||
row. A clamping implementation produces two identical frames, so the scenario
|
||||
discriminates. Both legs wrap the same way in both directions, and the only other
|
||||
difference in those frames is the vertical anchoring Decision 34 exempts.
|
||||
|
||||
## Decision 42 — the ink theme is applied, once at boot and once per selection
|
||||
|
||||
This renderer never asked the theme layer for anything. Its palette was built at
|
||||
import time from a hardcoded dark surface, so a named theme in settings, a theme
|
||||
picked in the dialog, and the light/dark probe were all inert: the port always
|
||||
painted the same colours whatever ink beside it painted. That is a functional gap
|
||||
rather than a cosmetic one, and it hid every colour divergence downstream of it.
|
||||
|
||||
Two call sites now apply the active ink theme. One is at boot, placed after the
|
||||
deferred auto-theme probe has drained, so the theme it maps is the one ink settled
|
||||
on and first paint is already correct. The other is inside the theme-selection
|
||||
handler, after the shared theme manager commits, so a pick takes effect without a
|
||||
restart. The mapping itself gained five tokens it did not carry: the two dimmed
|
||||
status colours behind the approval-mode chrome, the symbol colour behind shell
|
||||
mode, and the focused and default border colours behind the composer outline and
|
||||
the banner panel. Each has a value on both surfaces.
|
||||
|
||||
The dark/light mode helpers remain caller-less. The theme layer reaches the port
|
||||
through the ink theme, which already carries its own lightness, so the helpers are
|
||||
a second path to the same destination and nothing selects between them. Recorded
|
||||
under Follow-ups.
|
||||
|
||||
Verified on frames with a named theme pinned in the isolated settings directory.
|
||||
Under it this renderer's palette and syntax tokens move off the values the
|
||||
default-theme run produces, so the wiring is shown to carry a theme through, not
|
||||
merely to run. The banner wordmark was originally cited here as that evidence and
|
||||
was wrong to cite: its ramp was still hard-coded at the time and did not follow
|
||||
the theme on either count, which is Decision 47.
|
||||
|
||||
## Decision 43 — five token-level colour divergences, and one separator that no shipped theme can show
|
||||
|
||||
Applying the theme made the next layer visible: rows that drew the right shape in
|
||||
the wrong token. Five were found and corrected, each by reading the ink component
|
||||
that draws the row rather than by matching a colour to the eye.
|
||||
|
||||
The message metadata on both sides used the link token where ink uses the accent
|
||||
one; under the default dark surface the two happen to be the same colour, which is
|
||||
why it survived until a theme separated them. The goal card's colour map was built
|
||||
at import time out of the palette object's values, so it held whatever the palette
|
||||
was before any theme was applied and never moved afterwards; it now maps a card's
|
||||
colour name to a palette key and reads the palette when it draws. The footer's
|
||||
status rows were dim where ink paints them with the accent token. The footer's
|
||||
approval-mode label was one colour where ink colours it per mode, and its hint row
|
||||
was truncated as a single joined string, so narrowing the terminal dropped the
|
||||
coloured label whole instead of cutting it; the hint is now a list of coloured
|
||||
parts truncated part by part against the same budget, and the two joins ink makes
|
||||
— the inter-segment one and the single space before the queued badge — are each
|
||||
emitted only where both of their neighbours are actually present. The tool card's
|
||||
status glyph was bold where ink's is not.
|
||||
|
||||
A sixth row drew the right token pair the wrong way round: a separator in the
|
||||
authentication wizard, painted with the dim token where ink uses the default
|
||||
border one. It is swapped. What is recorded beside the swap is that no shipped
|
||||
theme can show it. Eleven of the fifteen built-in themes pass no semantic tokens
|
||||
to the theme constructor and so take its derivation, which reads both of these
|
||||
tokens off the same grey field of the colour set; the four that do pass a shared
|
||||
semantic set read them off the same grey field of that set; a custom theme that
|
||||
omits them takes the derivation too. The swap is therefore observable only under a
|
||||
user custom theme that sets exactly one of the two. It is a real divergence and a
|
||||
correct fix, and it moves no frame on any theme this repository ships.
|
||||
|
||||
Colour was verified by reading the styled capture both legs produce. In a
|
||||
truecolor terminal the banner panel's border carries the same source hex on each
|
||||
side, so the wiring of Decision 42 reaches the screen and these five token swaps
|
||||
land on the right values. The banner wordmark was read on the same frame and did
|
||||
not match: its ramp was sampled differently on the two sides, which is Decision 47. In a 256-colour terminal, read under a named theme, the border diverges
|
||||
because the two sides downconvert hex to an index by different rules, and the
|
||||
wordmark diverges from its second coloured column onward — indices 212, 218, 182
|
||||
against 68, 104, 103 — which is the same Decision 47 difference in source ramp
|
||||
showing through the conversion, not a second downconvert rule. Colour was read on
|
||||
these row families and no others, after the cursor cell of Decision 32, and it is
|
||||
a narrower dimension than the geometry the rest of this sweep rests on. The
|
||||
downconvert divergence belongs to the two libraries rather than to this port and
|
||||
is recorded under Follow-ups.
|
||||
|
||||
## Decision 44 — a settled subagent is one summary line, and no roster card
|
||||
|
||||
A foreground subagent that finished drew its result body in full, where ink draws
|
||||
one line: a glyph, the agent's name, and the description joined with its tool
|
||||
count, its own sub-agent count, its duration and its output tokens, plus the
|
||||
termination reason when it did not complete. The port drew the body as well as the
|
||||
card, so a settled agent occupied a screen ink had already given back.
|
||||
|
||||
The summary is derived in the event adapter, from the same segments ink's own
|
||||
helper uses, and only for a run that reached one of the three settled states — an
|
||||
in-flight agent draws no summary at all. Counting its sub-agents keys on the same
|
||||
name set ink's tool message uses, which was moved to a shared module so the two
|
||||
sides cannot drift. The line carries three colour runs on screen, as ink's does:
|
||||
the glyph in the tone colour, the name in the primary text colour and bold, the
|
||||
rest dim and truncated by the shared result-length rule.
|
||||
|
||||
The roster card went with it. The end-of-task event narrowed to an identifier, and
|
||||
both model layers now splice the card out when it arrives, so the settled row is
|
||||
the summary and nothing else. The card's completion flag and its statistics string
|
||||
were only ever read to build the row the summary now builds, and the local token
|
||||
formatter beside them had a shared equivalent, so all three were deleted rather
|
||||
than left unreferenced.
|
||||
|
||||
## Decision 45 — the thought body is markdown, and its label carries the elapsed time
|
||||
|
||||
An expanded thought printed its text as one dim run. ink renders the same text
|
||||
through the markdown component with the secondary text colour, which paints the
|
||||
plain text _and_ the headings that colour while leaving inline code and links
|
||||
their own. A heading inside a thought therefore read as body text here and as a
|
||||
heading there, and a link lost its colour entirely.
|
||||
|
||||
The body is now markdown, in a box indented by the same two columns the status
|
||||
glyph occupies, so the body gets ink's own width rather than running under the
|
||||
gutter. That width was measured rather than derived: an unbroken run inside the
|
||||
box breaks at ninety-four columns, which is what ink's body is given at the same
|
||||
terminal width. What the box does not decide is where the markdown breaks a line
|
||||
of ordinary prose, and there the two sides differ; that difference is recorded
|
||||
under Follow-ups. Because the renderer's own inline-text token wins over an
|
||||
element's foreground, a dimmed body needs its own token map: one is built beside
|
||||
the syntax style on every theme application, rewriting only the inline-text token
|
||||
and the heading ones to the dim colour and leaving the code and link tokens
|
||||
alone. A theme that omits the inline token keeps it omitted, so the no-colour
|
||||
theme still leaves that text uncoloured as ink does. The dim colour it rewrites
|
||||
to is read after the palette is assigned, not before.
|
||||
|
||||
Two smaller items on the same row. Its collapsed hint was missing the words that
|
||||
say the row can be clicked, which ink prints; the clickable flag it consulted was
|
||||
hardcoded off at the one call site, so it is gone rather than carried. And a
|
||||
thought still streaming had no duration, so its pending label printed without the
|
||||
elapsed time ink derives on every render; the label now derives it from the start
|
||||
stamp when no duration has been stamped yet.
|
||||
|
||||
## Decision 46 — three dialogs rebuilt on the shared chrome, and two frames corrected
|
||||
|
||||
The approval-mode, effort and output-style dialogs each carried their own local
|
||||
frame: their own border style, their own padding, their own title colour, their own
|
||||
footer-hint placement, and their own hand-written row text. Every one of those
|
||||
diverged, and none of them diverged in a way a per-dialog fix would have kept
|
||||
fixed. All three are rebuilt on the shared dialog frame, list and footer-hint
|
||||
components the rest of the port already uses, with their rows built from the same
|
||||
display-name and description helpers ink's own dialogs call — so the numbered rows
|
||||
now read as ink's read, and a future change to the shared chrome reaches them.
|
||||
|
||||
The effort dialog also gained the step ink's has for scoping the setting, reachable
|
||||
by Tab, with the workspace-priority warning ink prints when a workspace value
|
||||
overrides the one being changed. Its note when the configured effort is not
|
||||
available for the model now carries ink's two branches rather than one.
|
||||
|
||||
Two frames were corrected beside them. The shell dialog drew a square border, an
|
||||
accent-coloured title and a close hint ink does not print, with a column of padding
|
||||
either side that ink's box does not spend; it now draws a rounded border in the
|
||||
default border colour, a bold primary-coloured title, no close hint, and ink's own
|
||||
wording for a resumed session. The authentication wizard's frame spent one more
|
||||
column of padding than ink's, printed a close hint ink does not, and drew its
|
||||
separator twenty-four columns short; all three are corrected, and its link row is
|
||||
underlined and dim as ink paints it.
|
||||
|
||||
The machine leg was re-run at this head, widened first: seven scenarios were
|
||||
added for the thought card, the settled subagent, the authentication wizard, the
|
||||
two stale-cursor bursts, the wrapping list and a named theme, so the matrix now
|
||||
holds thirty-four scenarios and sixty-eight runs, none of them erroring. Of the
|
||||
ninety-seven checkpoints, twenty-six have identical non-blank content rows and
|
||||
sixty-nine diverge, carrying 365 rows only ink draws and 357 only this port draws
|
||||
once the two sampled checkpoints — the spinner's phrase rotation and the
|
||||
mid-stream indicator, which hold elapsed time and a phrase chosen per tick — are
|
||||
counted apart. The identical count therefore reads twenty-six where the previous
|
||||
head's read nineteen.
|
||||
|
||||
Against the previous head, 143 of the 152 plain captures common to both runs are
|
||||
byte-identical with the version the banner prints and the branch the footer
|
||||
prints normalised away. The nine that are not divide into the same four transient
|
||||
families the previous run named: three on the ink leg of the `@`-completion
|
||||
scenario, which in this run printed the extension-refresh notice the archived run
|
||||
had not, and six across the mid-stream indicator, `/stats` and the held-phrase
|
||||
row, on both legs. None of the nine is a capture this round's changes touch.
|
||||
|
||||
Most of the sixty-nine divergences are the vertical anchoring Decision 34
|
||||
exempts, the long-token wrap recorded under Follow-ups, or the elapsed time a
|
||||
settled card prints. The seven new scenarios read as intended: the subagent's
|
||||
summary row matches on both legs down to its separators, and the wrapping list's
|
||||
three checkpoints differ only in where the block sits, with the cursor on the
|
||||
same row of the list in every frame — one down keystroke from the last row lands
|
||||
on the first in both legs, which is the discrimination the scenario was written
|
||||
for. The thought card's two checkpoints do not match, and the part that is not
|
||||
the long-token wrap is the markdown break-point difference recorded under
|
||||
Follow-ups.
|
||||
|
||||
## Decision 47 — the wordmark gradient is sampled per line, off the theme's semantic ramp
|
||||
|
||||
The banner's wordmark carried a gradient on both legs and the two did not agree.
|
||||
Reading the styled capture cell by cell across the six lines of the mark — 221
|
||||
coloured cells — showed two independent causes, neither of which is visible in the
|
||||
other's absence.
|
||||
|
||||
The first is which colours are used. The port hard-coded the ramp the base light
|
||||
and dark colour sets declare. ink reads it off the active theme's semantic tokens,
|
||||
from the same candidates in the same order: the theme's own gradient when it
|
||||
declares a usable one, otherwise the secondary, link and accent text colours,
|
||||
otherwise no gradient at all — in which case ink paints the mark uncoloured rather
|
||||
than throwing. Eleven of the fifteen built-in themes take the constructor's
|
||||
derivation and so do carry their own ramp. The four that pass a shared semantic set
|
||||
— the two Qwen themes and the two ANSI themes — inherit the base ramp instead, and
|
||||
the gradient they declare never reaches the banner. The port now derives the ramp
|
||||
from the ink theme through those same candidates, resolves each stop the way it
|
||||
resolves the palette, and treats fewer than two survivors as no ramp.
|
||||
|
||||
The second is how the ramp is sampled. ink's gradient component wraps the
|
||||
renderer's per-node text transform, which runs once per laid-out line, so each line
|
||||
of the mark builds a ramp of its own over a step count of its own width. The steps
|
||||
between two stops are not a plain interpolation: the library behind it seeds each
|
||||
segment with a rounded share of the step count, rebalances until the segments sum
|
||||
to it, and then steps each segment from its own start colour. The port shared one
|
||||
ramp across all six lines and lerped on a plain fraction of the widest one. Both
|
||||
choices are wrong, and differently: sharing the ramp puts the middle stop at the
|
||||
same column on every line, where ink puts it one column earlier on the 36-column
|
||||
first line than on the 37-column ones, and the plain fraction leaves the shorter
|
||||
line one step short of the last stop. Sampling per line with the library's own
|
||||
substep arithmetic reproduces all 221 cells of the ink capture exactly; the
|
||||
previous formula reproduces 26 of them.
|
||||
|
||||
The arithmetic is reimplemented rather than imported — some twenty-five lines —
|
||||
because the library that defines it reaches this package only as a transitive
|
||||
dependency of the component ink uses, and depending on it directly would mean
|
||||
declaring it. It is pinned by tests against the hexes read off the capture, at both
|
||||
line widths, and those tests were shown to fail against the previous formula.
|
||||
|
||||
## Decision 48 — two settings ink reads had no reader here
|
||||
|
||||
A sweep for settings that change what ink draws found two that this renderer
|
||||
accepted from the same merged configuration and then never looked at: the clock
|
||||
stamp above an assistant row, and the switch that collapses a tool call to its
|
||||
header. Both were confirmed by reading every reference to each key in the package:
|
||||
the only readers were ink's own history display. A setting with no reader is worse
|
||||
than a missing feature, because the configuration UI offers it and appears to
|
||||
honour it.
|
||||
|
||||
The stamp needed a time on the shared item type, and two sources for it. A live
|
||||
turn has no recorded time to read, so the block is stamped when it opens, with the
|
||||
fold's own clock, and later deltas appended to the same streaming block leave that
|
||||
stamp alone — one message, one stamp. A resumed session rebuilds the transcript
|
||||
from the recording instead, where each assistant record carries its own time; that
|
||||
record holds several text parts, so only the first text run of it is stamped and a
|
||||
leading reasoning block does not consume the stamp. Shipping the live half alone
|
||||
was the divergence the follow-up entry warned about: a resumed session would have
|
||||
shown no stamps at all. The label itself is ink's, character for character — a
|
||||
24-hour, zero-padded clock in brackets off the English locale, which is what keeps
|
||||
`23:00:00` from rendering as a 12-hour clock on a host whose locale differs.
|
||||
|
||||
The collapse is ink's rule with ink's four exceptions. A card collapses only when
|
||||
the setting is off and the card was not clicked open, and the app-wide full-detail
|
||||
toggle is not holding it open, and the call is not still waiting for an answer.
|
||||
That last exception is the one that matters: the card is the only surface carrying
|
||||
the payload being approved, so a collapsed card would ask the user to approve
|
||||
something they cannot read. Ink chooses between two hints by whether the card is
|
||||
clickable, and the mouse is on here except under the screen reader, so it takes
|
||||
the clickable branch and names the click alone — the same call the thinking
|
||||
card's hint already makes.
|
||||
|
||||
Both halves are pinned by tests that were shown to fail against the previous
|
||||
behaviour: dropping the setting gate, dropping the pending exception, stamping
|
||||
every text run instead of the first, reading a 12-hour clock, and naming both
|
||||
affordances in the hint where only one applies — each was shown to break
|
||||
assertions, and only those.
|
||||
|
||||
## Decision 49 — `@` completion asks every source ink asks, behind ink's tabs
|
||||
|
||||
This renderer's `@` completion asked the file index and nothing else. ink also
|
||||
completes prior sessions of the current project, MCP servers and their resources,
|
||||
and installed extensions, and — once more than one category is present — draws a
|
||||
tab bar to move between them.
|
||||
|
||||
The search itself is reused rather than reimplemented. ink's completion hook takes
|
||||
a pattern and two callbacks and owns everything else: the crawler's lifecycle, the
|
||||
short delay before it admits a search is slow, the cancellation of a search a newer
|
||||
keystroke superseded, and a merge order it documents as an invariant, with sessions
|
||||
appended last on every path. Reimplementing that would have meant duplicating the
|
||||
invariant in a second place, and the hook has nothing renderer-specific in it. What
|
||||
was not reused is ink's navigation state: it keeps its own highlighted index and
|
||||
its own visible window, and this composer already has both, built to survive a
|
||||
batch of keystrokes handled inside one render. So only the category rules were
|
||||
ported — the fixed tab order, the tab set a result list implies, the filter, the
|
||||
wrapping step, and the fallback to the first tab when a newer result set no longer
|
||||
contains the one on screen. That last one is derived during the render rather than
|
||||
corrected after it, which is one render pass cheaper and visibly identical.
|
||||
|
||||
Reusing the hook exposed a hazard the local search never had. Leaving `@` — the
|
||||
buffer cleared, a slash typed instead — makes the shared hook dispatch a reset, and
|
||||
that reset's publication lands after the render in which the same keystroke already
|
||||
published the slash results, wiping them. Both forwarded callbacks are therefore
|
||||
gated on the mode this composer is actually in, which is updated synchronously as
|
||||
the keystroke is handled. The gate also closes, for `@`, a hazard ink still has: a
|
||||
search resolving after the buffer was cleared can no longer repopulate a dropdown
|
||||
over an empty composer and steal the next Enter. The guard on the shell-mode
|
||||
keystroke stays, because slash argument completion is still driven locally and can
|
||||
still race it.
|
||||
|
||||
Two details of the drawing follow ink rather than this renderer's slash list. The
|
||||
tab bar owns the bare left and right arrows only while it is up, with every
|
||||
modifier pinned off so word movement and any terminal binding still reach the
|
||||
buffer. And in mention mode a label column is shared only by the rows that carry a
|
||||
description — a bare path keeps the full width — where in slash mode every row
|
||||
shares one. The tab labels are exported from ink's own list component instead of
|
||||
being copied, so the two renderers cannot drift on a translation.
|
||||
|
||||
## Decision 50 — the card header is one wrapping block, and both states share one shape
|
||||
|
||||
Frame-verifying the collapse Decision 48 wired turned up two defects in the tool
|
||||
card's header, one of them a regression that only the frames could show.
|
||||
|
||||
The first is the collapsed card's own tree shape. Collapsed and expanded returned
|
||||
different structures, and this renderer's React binding reconciles children by
|
||||
position and type and never clears a prop a re-render drops — the hazard Decision
|
||||
11 named, and a site its survey judged unaffected, because the collapsed branch
|
||||
sets a width the expanded branch never sets, which is exactly the removal case.
|
||||
So the box holding the two-column status glyph was reconciled onto
|
||||
the box the expanded header row uses, kept the width it had been given, and the
|
||||
expanded card drew the glyph and the first letter of the tool name with everything
|
||||
else clipped away. Both states now return one shape: the same header row, with the
|
||||
name and hint as separate runs while collapsed and as one wrapping block while
|
||||
open, and the body simply absent when collapsed. A test pins the part that jsdom can
|
||||
see — that the header row is the same element instance before and after the toggle,
|
||||
and that the glyph's box is the only descendant ever carrying a width — which is what
|
||||
fails if the two shapes come apart again. The width residue itself is a renderer
|
||||
behaviour the unit runtime cannot reproduce; it was read off the terminal, and
|
||||
confirmed absent the same way after the fix, including across a resize.
|
||||
|
||||
The second is the expanded header's own layout. It was several text nodes side by
|
||||
side: the name, the description, the arrow that marks a waiting call. Flex siblings
|
||||
cannot reproduce what ink draws, because ink treats the name and the description as
|
||||
one wrapping block whose width is the header's own inner width — the card's width
|
||||
minus the two columns the status glyph occupies. Three consequences followed, all of
|
||||
them visible in a frame: the description's leading space was lost, so the name and
|
||||
the command ran together; wrapped rows were indented by eight columns where ink
|
||||
indents by four, because a sibling starts after the name rather than under it; and
|
||||
the arrow sat where the row ended rather than after the block's last character. The
|
||||
header is now a single text node carrying inline styled runs — the name bolded, the
|
||||
description and the arrow dim, the arrow in the primary colour as ink paints it — so
|
||||
the wrapping is the renderer's own and the geometry matches. The block's width is
|
||||
the same value the truncation helper already computed the row cap from, which is why
|
||||
the cap and the drawing now agree.
|
||||
|
||||
Two residuals remain and are recorded under their own entries rather than here: the
|
||||
single cell between the name and the description is uncoloured in ink and dim in
|
||||
this port, which is the uncoloured-text divergence, and a token longer than the
|
||||
block still breaks earlier here than in ink, which is the long-token wrap.
|
||||
|
||||
The stamped assistant row got the same remedy, because its two shapes differ the
|
||||
same way and the setting that chooses between them is dialog-settable and needs no
|
||||
restart — so a row already on screen switches shape mid-session. Its branches now
|
||||
carry keys, and a test pins the part that matters: after the flip the unstamped row
|
||||
is gone rather than reshaped in place, which is the assertion that fails when the
|
||||
keys are dropped.
|
||||
|
||||
The machine leg was re-run at this head, widened by five scenarios first: the stamp
|
||||
with the collapsed card, the `@` categories, the away recap, the editor dialog, and
|
||||
the session picker's Space preview. The matrix now holds forty-three scenarios and
|
||||
eighty-six runs — thirty-eight at 100×40, the three transcript arms at 100×30, the
|
||||
narrow boot at 60×24 and the chip row at 58×40 — and not one run errored. Nine ended
|
||||
on an idle timeout instead of a settled frame, and all nine are legs that have always
|
||||
timed out: the auth wizard's model list on ink, whose spinner never goes idle, and the
|
||||
two multi-question arms.
|
||||
|
||||
Of the one hundred thirty-one checkpoints, thirty-three have identical non-blank
|
||||
content rows. One family dominates the other ninety-eight: the context-file notice
|
||||
names a path long enough to be a single unbreakable token, the two legs break it at
|
||||
different columns, and nearly every frame that shows the notice shows it differently.
|
||||
Dropping the notice and the path fragments it wraps into from both legs leaves
|
||||
seventy-three identical and fifty-eight divergent, two of them the sampled checkpoints
|
||||
— the spinner's phrase rotation and the mid-stream indicator. Counting those two
|
||||
apart, the rest carry 258 rows only ink draws and 288 only this port draws.
|
||||
|
||||
Those fifty-six fall in eleven families by primary cause, four of them new to this
|
||||
pass. Nine are non-deterministic and no fix can close them: the two sampled
|
||||
checkpoints, the two single-frame spinner phrases, the three stamp frames where the
|
||||
clock moved on its own, and the elapsed milliseconds on the two sub-agent frames.
|
||||
Nineteen are Decision 26 — this port still shows the banner ink has scrolled off, so
|
||||
identical content sits at different rows. Ten carry a defect no earlier pass had
|
||||
shown: the notice row keeps glyphs from the banner row it displaced, because the
|
||||
renderer never repaints a cell whose new content is a space. Four are a dialog that
|
||||
does not stretch — ink sizes its approval-mode box to the rest of the viewport and
|
||||
fills it with blank rows, this port sizes it to its content, and every row inside the
|
||||
box is word for word the same. Four are the card header's long argument wrap, four are
|
||||
the trailing scrollbar glyph, two are a scroll offset, two are a row that appears in a
|
||||
different order, two are a truncation the two legs disagree about, one is the expanded
|
||||
thinking body's indent, and one is the gated-server dialog's paragraph continuation.
|
||||
|
||||
The vertical comparison is no longer reported, because Decision 26 made it
|
||||
meaningless: this port pins the composer to the bottom of the viewport where ink pins
|
||||
it to the top, so 128 of the 129 reconstructed frame pairs differ in position alone.
|
||||
Content rows are the comparison that survives that decision.
|
||||
|
||||
## Decision 51 — the editor dialog reports a failed save where the user can see it, and its two lists are the shared numbered window
|
||||
|
||||
`/editor` persists a preference through the shared settings object, and that write can throw — a read-only file, a scope the process cannot reach. ink catches the failure and prints it inside the dialog, so the user reads why nothing changed and the dialog stays open to try another scope. This renderer swallowed it: the catch closed the dialog on the error path exactly as on the success path, so a failed save was indistinguishable from a saved one and the preference quietly did not move. The write now records an error string instead of closing, the string renders in red above the frame, and the dialog stays mounted. A test drives a throwing setter and asserts both halves — the message on screen and the frame still open — which is what fails when the catch goes back to closing.
|
||||
|
||||
The two lists inside the dialog were hand-drawn rows, which cost them the index column and the scroll window every other list here has. Both now run through the shared select hook and widget, so the editor step and the Apply-To step number their rows, window them instead of growing past the frame, and step over the disabled row rather than landing on it. The widget is the same one the confirmation dialogs use, so the numbering, the highlight and the wheel match the rest of the port instead of a one-off; the editor list also re-seats its cursor on the chosen scope's stored preference when the user comes back from Apply-To, which is how ink keys its own.
|
||||
|
||||
The dialog drew a title row of its own above the two columns, and its bottom hint wrapped to a second line where ink truncates it inside the left column. The title row is gone — the two column headers are the top rows now, as ink's are — and the hint is clipped to the column's own budget, 45% of the content width less the two columns of padding, which is the truncate box ink puts it in. Tests pin each half: no chrome title row of the dialog's own, and the hint clipped to the left column with a single ellipsis rather than wrapped.
|
||||
|
||||
## Decision 52 — `/resume` and `/delete` share ink's session picker, and delete commits a checked set
|
||||
|
||||
`/resume` and `/delete` each drew their own session rows, and neither matched the picker ink mounts for both. The rows are now one component, built on the same pieces ink's SessionPicker uses rather than beside them: the shared filter and page size, the relative-time and message-count formatters, the truncation helper, and the search-input hook with its printable-character test. A row carries the two-column cursor marker — an up or down arrow at the window's edges — the title clipped to its own budget, and a meta line of relative time, message count, branch and disabled hint in ink's order. The visible window is derived from the terminal height the same way ink derives it, so the list scrolls rather than running off the frame.
|
||||
|
||||
Delete also lacked ink's bulk path. It now mounts the picker with multi-select on and reuses ink's own delete command for both the single and the batch case, so the guard against deleting the live session, the mutex and every outcome message are shared rather than re-worded. Enter commits the checked set ordered by the full list and filtered to the enabled rows, so a search that narrows the view never silently drops a check made before typing; the live session is disabled in place rather than filtered out, matching ink. Resume keeps the single-select picker, with the command's pre-filtered list when `/resume <title>` matched more than one session. The cursor and the checked set both sit behind ref mirrors, so a burst of Space and arrow keys inside one read commits the set the user actually ticked rather than the one the last render drew.
|
||||
|
||||
The structure and the batch path are pinned by tests over the rendered rows — the two-line shape, the unknown-count case, the checkbox commit, the disabled live session, the checks hidden while multi-select is off, the window's edge markers and pagination — each shown to fail against the previous hand-drawn rows. The picker's own height is still fixed rather than content-sized; that is recorded under Follow-ups rather than changed here, since it belongs to the spacing family Decision 16 describes.
|
||||
|
||||
## Decision 53 — the auth wizard's model step focuses by colour and windows to eight rows, inside ink's square frame
|
||||
|
||||
The onboarding wizard's model step drew a `›` cursor glyph beside the focused row, listed every recommended model at once, and had no search field. ink's model step does none of those: it conveys focus by colour alone, windows the list to eight rows, and puts a search field one Tab away that filters the recommendations as the user types. The step now matches — the glyph is gone, and the focused row is green where a checked-but-unfocused row takes the accent colour and a plain row the body colour; the list is a slice of the filtered models capped at ink's shared eight-row constant and scrolls as the focus moves; and the search field uses ink's own option-label and search-text helpers, so the rows read identically and cannot drift on a translation. Those constants and helpers are imported from ink's provider-setup module rather than copied.
|
||||
|
||||
The dialog's frame was rounded like every other dialog here, but ink frames its auth dialog with a single border, so the wizard now asks the shared shell for square corners and leaves the rest of the port rounded. It also lacked ink's branch for a provider that ships no recommended models: where the list would be empty, ink drops the recommendations and the search entirely and asks for comma-separated model IDs in a free-text field, with the provider's own defaults as the example. That branch is now drawn, and a separate empty-search result — models exist but none match the query — says so in ink's words rather than showing a blank list.
|
||||
|
||||
Tests pin the parts jsdom can see: the no-recommendations branch for a provider without models, the model-IDs error surfacing on an empty submit, the search field filtering the recommended list one Tab away, and the custom-ID field keeping its own caret. The burst cases the step already had — a held Space applying every tick to the same checkbox, and an Enter submitting the tick made in the same read — still pass against the colour-only focus, which is what fails if the glyph comes back or the window stops scrolling.
|
||||
|
||||
## Decision 54 — a descriptive list stacks its description under its label and marks the pick in green
|
||||
|
||||
Three dialogs draw a list whose rows carry a label with a description beneath it — the auth wizard's radio steps and the arena's stop and select dialogs. All three indented the description two columns past its own label, because the description sat in a sibling that started after the marker rather than under the label, and all three marked the picked row in the body colour where ink uses green. Each row is now a two-column box: a fixed two-column marker holding the `›` or a space, beside a single growing column that stacks the label and the description, so the description lines up under the label instead of past it. The selected row's marker and label take the success green ink uses, and an unselected row stays body-coloured with a dim description.
|
||||
|
||||
The change is read off the rendered rows in the three dialogs' own tests, which assert the description's column against the label's and the selected row's colour. The row spacing is untouched — this is the indent and the colour, not the rhythm between rows.
|
||||
|
||||
## Decision 55 — the away recap fires because the entry mounts the hook ink's container does
|
||||
|
||||
ink fires a recap when the user returns to a session left idle past a threshold: its container mounts a hook that watches the focus and idle edges and, on return, appends a summary row and records it. This renderer never mounted that hook, so the away recap never fired — the setting existed, the hook existed, and nothing between them ran. The hook now mounts in the entry, which is where the live transcript and the streaming-to-idle edge already are, so its gate reads the rows it needs without a second copy of them. Focus comes from a new hook of this renderer's own: @opentui/core already parses the terminal's focus and blur events, but nothing switched focus reporting on, so this hook owns the `?1004` mode for the OpenTUI leg, starts focused, and re-asserts focus on any keypress — ink's own tmux workaround, since a session that does not forward focus events would otherwise stay blurred forever. Only one leg is ever mounted, so the two never both write the mode.
|
||||
|
||||
The recap the hook returns lands as its own transcript item, which stops a streaming assistant block before it appends the summary, so it cannot splice into a turn still in progress. The hook records that recap on its own — the result phase of a `/recap` command, never the invocation — mirroring the manual command, so the auto-fired summary reaches the session log the same way. Whether a resumed session redraws it is the result-phase replay gap already under Follow-ups, which covers every slash-command result row rather than this one alone.
|
||||
|
||||
Tests cover the focus hook's edges — the mode written on mount and cleared on unmount, the focus and blur events, the keypress re-assertion, the detach, and a stdout that throws — and the recap item's projection into the transcript. The hook's own firing is ink's shared code and is tested there; what this decision adds is the mount, the focus source and the item, each pinned here.
|
||||
|
||||
## Decision 56 — the footer's model segment carries the reasoning effort
|
||||
|
||||
ink's status line renders a model-with-reasoning preset item, so its model segment carries the reasoning effort beside the name — or `reasoning off` when the model has none. This renderer's footer printed the bare model id and dropped the effort. The segment now goes through the same shared formatter ink's preset uses, which strips a leading provider tag, appends the effort when there is one, and says `reasoning off` when reasoning is explicitly disabled. The effort comes from the same display helper the rest of the port reads, so the footer and any other surface that names the model agree.
|
||||
|
||||
A test pins the segment against the preset's output — a model with reasoning off reads as its name followed by `reasoning off` — which is what fails when the footer goes back to the bare id. The divergence was first read off a reconstructed frame, where the two legs' model segments differed by exactly the effort suffix.
|
||||
|
||||
## Decision 57 — the pointer follows `ui.mouseTracking`, and so do the two expand hints
|
||||
|
||||
ink reads `ui.mouseTracking` in two places: it decides whether the renderer captures the pointer at all, and it decides which of two expand hints a collapsed card shows — `click to expand` while the mouse is live, `ctrl+o to expand` when it is not. This renderer read neither. It created its renderer with the pointer always on, and both expand hints — the collapsed tool card's and the thinking card's — hard-coded the clickable branch, so they advertised a click even under `ui.mouseTracking: false`, a setting with no read point here at all. Both now read it: the renderer is created without pointer capture when the setting is off, which hands right-click menus and OSC 8 link clicks back to the terminal as ink's does, and a live mouse flag is threaded into the transcript so each hint takes the branch that matches.
|
||||
|
||||
The hint follows ink's own choice rather than a new rule — clickable while the pointer is live, key-only when it is not — which is the same call the thinking card's hint already made. The flag defaults to live, so the default setting changes nothing on screen; turning it off drops the click affordance from both hints and from the pointer at once. Decision 48 took the clickable branch on the assumption that the mouse is on here except under the screen reader; this decision makes the setting a second gate on that branch, and Decision 58 supplies the screen-reader leg.
|
||||
|
||||
Both directions were confirmed on a real terminal: a dedicated scenario with the setting off shows no click affordance in either leg — the collapsed card and the thinking card both read `ctrl+o to expand` — while the same bundle with the default setting still offers the click. Unit tests pin the wiring beside it: the transcript receives the flag as false when the setting is off, and each hint's text follows it.
|
||||
|
||||
## Decision 58 — a screen-reader session stays on ink
|
||||
|
||||
Screen-reader mode needs plain-text, append-only output on the main screen with no mouse, so a screen reader reads each line once as it lands. This renderer has no path for that: it draws in place on the alternate screen with differential redraws and a live pointer, which a screen reader cannot parse. Serving OpenTUI under the flag would hand such a user in-place redraws that are worse than useless. So the renderer-selection gate now treats screen-reader mode as a second ink-only capability, beside the runtime probe it already had: when the flag is on the gate keeps ink, and in strict mode it throws rather than falling back silently.
|
||||
|
||||
The check sits before the runtime probe and outranks it, so a screen-reader session on a runtime that cannot initialise the native FFI still reports the screen-reader reason rather than the FFI one — the capability, not the platform, is why ink is serving. The entry passes the configured screen-reader flag into the gate, so this is a live switch rather than a dead parameter. The plain-text and screen-reader policy modules this renderer carries stay unwired: they are imported only by their own tests, and threading them into a render path is follow-up work, not this change. This decision is the gate that keeps such sessions on ink until they are.
|
||||
|
||||
Tests pin both the fallback and its reason, and a mutation that moves the screen-reader check after the runtime probe fails the case asserting the screen-reader reason wins on an unsupported runtime. On a real machine, strict mode throws naming screen-reader rather than the FFI; on a supported bun runtime a screen-reader session stays on ink while the identical run without the flag serves OpenTUI.
|
||||
|
||||
## Decision 59 — Space previews a session, in a tree of its own rather than inside the list's frame
|
||||
|
||||
The picker Decision 52 built could only act on the row under the cursor. ink's picker has a second mode: Space loads the highlighted session and replaces the list with a preview, so the user can read a transcript before committing to resuming or deleting it. That mode was absent here, which left `/resume` a blind choice among titles and made the picker's own `Space to preview` hint advertise nothing.
|
||||
|
||||
The preview is returned as its own tree rather than as a swapped body inside the list's frame, because ink's has no border at all and its transcript spans the full inner width — a narrower body inside the framed shell would not have matched either the geometry or the reading width. Both trees carry a key, and that is load-bearing rather than decorative: this renderer never clears a prop a re-render drops, which is the failure Decision 11 records, so returning the framed list's box with the border style simply omitted left the border drawn on screen. Keying the two shapes apart forces an unmount instead of a reuse.
|
||||
|
||||
The transcript comes from replaying the resumed session's events with no config passed in, so a tool group degrades to its name alone. That is not a shortcut — ink's preview passes a null config to the same builder and gets the same degradation, so the two agree on what a preview is willing to show. The meta line counts messages from the list entry when it carries a count and otherwise over the unique user and assistant uuids in the loaded conversation, which is ink's own fallback, and orders its segments count first — the reverse of the row's time-first order, again as ink does.
|
||||
|
||||
While the preview is up it owns the whole keyboard: the picker's handler returns before it reads anything else, so a typed letter cannot start a search and an arrow cannot move a cursor that is no longer visible. Esc and ctrl+c come back to the list with the cursor, the checks and the query untouched; Enter resumes the previewed session. A load that resolves after the user has left is discarded rather than drawn over whatever they moved to. The mode is mounted for `/resume` only. `/delete` runs the picker in multi-select, where Space is the checkbox toggle, and the preview is off whenever multi-select is on, so the two never compete for the same key.
|
||||
|
||||
Two of ink's guards were not ported, on purpose. Its picker throws when a caller asks for multi-select and preview together; this one resolves the same request by preferring multi-select. Neither production caller asks for both, and a precedence rule costs nothing observable where a throw would turn a wiring mistake into a crash. Its preview is also gated on a session service being present while that mode has already switched off the picker's own keys, so a missing service leaves ink's picker with no way out of a mode it cannot draw; here Esc returns to the list regardless.
|
||||
|
||||
ink's own preview carries a defect that was not ported. Its loading branch is an ordinary box and its ready branch is a static block, and the height difference between them makes ink erase content it has already written to the static region and scroll the screen by several rows: the frame this sweep captured on the ink leg was blank, while the raw byte stream beside it carried the whole transcript. The port keeps one shape for both states, so neither the erase nor the scroll can happen. The ink leg's static output was still read as the reference for the intended layout, and the ported body wraps at the same columns and breaks at the same words.
|
||||
|
||||
Nine cases pin the mode over the rendered rows: the swap and its Enter, the Esc that preserves the cursor and the query, the preview's exclusive claim on the keyboard with ctrl+c backing out rather than cancelling, both error wordings, the uuid-deduplicated count, Space staying with the checkboxes under multi-select, nothing at all drawn for a flow that did not opt in, and a late load dropped. Two scenarios cover it on a real terminal — one asserting the preview's frame, its borderless geometry and the byte-identical list after Esc, the other asserting that Enter actually restores the session into the main transcript rather than only closing the dialog. Not verified on a real machine: the two error branches, which need a corrupt session file to reach, and scrolling inside a preview longer than the viewport.
|
||||
|
||||
## Decision 60 — the row glyph's gap is a column, not a character
|
||||
|
||||
The user row and the assistant row each printed their status glyph and the space after it inside one text node, beside a growing sibling that carries the body. When the body is long enough to wrap, the sibling takes the full width and the trailing space is squeezed out, so an answer read `◆︎Answer` with no gap at all. Nothing in the frame matrix had shown it before, because every seeded body was short enough to leave the space room; the preview Decision 59 added was the first to put a deliberately wrapping answer in a transcript.
|
||||
|
||||
The gap is now a fixed-width box holding only the glyph, sized by the same constant the tool card's status column uses, so it cannot be traded away by a sibling that needs the room. This is the same failure the warning row had, and the same fix; the shape is worth having in one place rather than rediscovered per row.
|
||||
|
||||
A test pins it on both rows — the glyph sits in a two-column box of its own and carries no surrounding whitespace — and fails when either row goes back to an inline space. The frame that exposed the defect now shows the gap and wraps at the columns ink wraps at. One row of the same family was left alone deliberately: the subagent summary line also prints its glyph and space inline, but its three siblings are all plain text and none of them grows, so the squeeze cannot reach it. Changing it would be a guess at a defect no frame has shown.
|
||||
|
||||
## Decision 61 — the harness routes a side query by its own marker, not by the absence of the main agent's
|
||||
|
||||
The comparison harness answers every request from one fake endpoint, and that endpoint
|
||||
serves more than the conversation the scenario scripted. A turn can be followed by a
|
||||
session recap, a follow-up suggestion, a title, a next-speaker check, a memory recall,
|
||||
a compression pass, and — when the workspace has managed memory on — an extraction
|
||||
subagent that reads and writes files. Only the main turn and the subagents it spawns
|
||||
should consume the scenario's response table; a side query that takes a table entry
|
||||
silently shifts every later reply by one, and the frame that comes out looks like a
|
||||
rendering defect rather than a harness defect.
|
||||
|
||||
The first test was the main agent's system-prompt prefix, and a request that did not
|
||||
carry it was treated as a side query. That is the wrong shape of test, because the
|
||||
prefix is not one string: each subagent carries the prompt its own definition gives it,
|
||||
so a built-in general-purpose agent and a user's `.md` agent differ, and enumerating
|
||||
them is not a thing a harness can keep true. The subagent frame lost its reply.
|
||||
|
||||
The replacement — streaming plus a tool declaration means an agent conversation — was
|
||||
worse, because it looks like a rule about the wire and is not. The managed memory
|
||||
extraction subagent streams and declares tools; it is a subagent in every sense except
|
||||
that it belongs to the memory feature rather than to the turn. It took the table entry
|
||||
the scenario had written for the second reply, and the recap scenario timed out waiting
|
||||
for a row that had already been spent. The follow-up suggestion defeats the rule from
|
||||
the other side: it reuses the main agent's prompt verbatim and is identifiable only by
|
||||
the last user message.
|
||||
|
||||
So the test runs the other way. Each side query is recognised by a marker it owns —
|
||||
the recap's own instruction, the suggestion's mode header, the compression directive,
|
||||
the extraction subagent's system prompt — and a request that is not streaming is a
|
||||
JSON side query. Everything else is an agent conversation and takes the table. The
|
||||
three answers a side query gets are the recap's text, an empty string where an empty
|
||||
string means "no suggestion" and "nothing to compress", and the canned empty-recall
|
||||
JSON. Re-routing on a positive marker rather than on the absence of one is what makes
|
||||
the harness survive a new side query: an unrecognised one lands in the agent bucket
|
||||
and shifts a reply, which the frame shows, instead of being silently answered from a
|
||||
table it was never meant to read.
|
||||
|
||||
The re-run confirms it on both the behaviour and the routing. The subagent scenario
|
||||
went from erroring after two minutes to settling in twelve seconds, and the recap
|
||||
scenario from timing out on its second reply to settling in seventeen. Every request
|
||||
the full matrix made was then classified: eighty main turns and two subagent turns
|
||||
took the table, and twenty-six JSON side queries, two recaps and fifty-six suggestion
|
||||
calls did not. No table entry went to anything but an agent conversation.
|
||||
|
||||
## Decision 62 — the palette carries a revision, because the banner's memo cannot see a repaint
|
||||
|
||||
Decision 42 made the theme live and Decision 47 took the wordmark's ramp off it, and between them they left a hole no boot frame could show: the banner is memoised, and nothing a theme selection changes appears in its dependency list. The palette is one object mutated in place, so its identity survives `/theme`; the settings object the theme manager writes into is mutated as well, so its identity survives too; and the config is untouched. A banner that was already mounted therefore kept painting the ramp and the border colour it had been built with, and only a resize or a restart brought the new theme in. Every frame the matrix captured boots with the theme already applied, which is why this survived the sweep.
|
||||
|
||||
The palette now carries a revision counter, bumped by both mutators, and the banner's memo depends on it. A module-scope counter is not a dependency the hooks lint accepts — it warns that mutating an outer value re-renders nothing — so the line carries a suppression with the reason beside it. The suppression is honest rather than a way past the rule: the re-render comes from the theme-selection handler's own state write, and the counter is what tells the memo that the values it closed over were repainted underneath it.
|
||||
|
||||
A test repaints a mounted banner — the same config and settings objects are held across a theme change, and the wordmark's first cell is asserted before and after. Dropping the counter from the dependency list fails it with the old ramp still on screen, which is the mutation that shows the case discriminates. Not verified on a real machine: the light/dark mutator has no production caller (Follow-ups), so only the named-theme path is exercised, and no frame was captured of a theme picked mid-session.
|
||||
|
||||
## Decision 63 — the ref mirror reaches two handlers Decision 41 missed
|
||||
|
||||
Decision 41 recorded the stale-read class as closed for cursors. It was not. Two handlers sit outside the shared select hook it converted, and both were read off the source rather than off a frame.
|
||||
|
||||
The rewind restore-option list keeps its cursor in a reducer, which the shared hook does not reach. Its Enter read the reducer's answer, and a burst cannot have delivered that answer yet: an arrow auto-repeat followed by Enter restored the option highlighted before the arrows moved. The clamp the reducer applies is now a function both sides call, and the handler advances its own mirror before it dispatches, because inside a burst a dispatch does not render and a mirror re-synced during render would still hold the pre-burst index. The render-time write beside it is what keeps the mirror honest across the resets the reducer performs on cancel and on a failed restore.
|
||||
|
||||
The permissions dialog's two free-text fields — the rule and the workspace directory — were listed under Follow-ups as string state still read from the closure, and they are the one member of that list whose trigger is a single gesture rather than two keys of different meaning: a pasted path followed by Enter arrives in one read, so the field submitted the empty buffer it held before the paste and the dialog stayed put. Both now sit behind the mirror the cursors use, which lives in a module of its own rather than inside any one dialog, and their backspace and their append write through the mirror as well, so a burst that edits and then submits is coherent end to end. What remains open is the state that gates a branch rather than feeding a submit — a view name, a step, a search query — and stays recorded under Follow-ups.
|
||||
|
||||
Three cases pin the rewind handler: a burst that lands on the third option, a burst that clamps into `Never mind` and then re-syncs the mirror for the next one, and keys dropped while the diff still loads. Three pin the permissions fields, one of them editing inside the burst. All six were run against the pre-fix code and fail there — the rewind burst reports the first option instead of the third, and the permissions fields submit nothing at all. Neither defect was reproduced on a real terminal: the burst needs a paste or a held arrow inside one read, which the harness cannot type.
|
||||
|
||||
## Decision 64 — the mirror reaches two more reads, and one of them is not a number
|
||||
|
||||
Decision 63 closed the two handlers outside the shared select hook. The class had two more members, again read off the source rather than off a frame.
|
||||
|
||||
The auth wizard's model step holds which of three targets has focus — the custom-ID field, the search field, the list — in plain state, and its arrows, its Space and its paste handler all read that state off the closure. A held arrow followed by Space therefore typed a space into the field the arrows had already left, instead of toggling the row they had reached. The composer's `@` category tab stepped from the tab the previous render showed, so three arrows inside one read moved one tab rather than three.
|
||||
|
||||
The cursor mirror only carries numbers, and a category is a string union, so the mirror was generalised beside it: one half for a numeric cursor, one half for any other value, both writing state and ref together. The wizard's focus and the composer's tab now sit on those two halves, and eight call sites take the mirror from that one module rather than growing a local shape each — the shared select hook, the session picker, the permissions dialog, the composer, the auth wizard, and the MCP, settings and arena dialogs.
|
||||
|
||||
The tab is the instructive one, because it already used a functional update and still read stale. Inside a burst a functional update chains correctly — the third arrow does see the second arrow's write — while the value the handler closed over never moves, so an expression mixing the two still starts from the pre-burst tab. The fix is to read the mirror and do the arithmetic on it explicitly, which is what both handlers now do; the wizard's arrows in particular stopped deriving their next index from the render's copy.
|
||||
|
||||
Three cases pin this: a three-key burst that lands the Space on the row the arrows of the same read reached, a two-key burst that toggles the row an arrow moved to, and three arrows of one read that carry the tab to Extensions. All three fail on the pre-fix code — the two wizard cases invert which radio glyph is filled, and the tab case finds no extension row and no file row either, because every arrow stepped from `all`. Neither defect was reproduced on a real terminal, for the reason Decision 63 gives. The wizard's review rows still read the closure, and stay recorded under Follow-ups.
|
||||
|
||||
## Decision 65 — the approval dialog remembers the row its arrows landed on
|
||||
|
||||
ink keeps its own highlighted-mode state and seeds the list index from it, so coming back from the scope step restores the row the arrows last highlighted rather than the mode the config happens to hold. Here the list was seeded from the config every time the scope step closed, which put the cursor back on the current mode and quietly threw away the walk the arrows had made — a user who moved down two rows, checked the scope, and pressed Enter wrote the mode they had started on.
|
||||
|
||||
The dialog now keeps the highlighted mode the way ink does, takes it from the list's highlight callback, and seeds the list from it. The scope keeps its own key: the scope is what the trip changes, and the highlight is what survives it.
|
||||
|
||||
One case walks the mode list down a row, takes the scope step to Workspace and comes back, then asserts the cursor is on the row the arrows reached. Seeding from the config instead of from the highlight fails it. The case looked green under that mutation at first, and the reason is worth keeping: Enter on the scope step without moving the scope cursor writes the scope it already had, so the key the list re-syncs on never changes, the list is never re-seeded, and the cursor sits where the arrows left it under either version. Moving the scope cursor before Enter is what makes the case discriminate. A re-sync keyed on a value has to see that value actually change, and a case that exercises it has to change it.
|
||||
|
||||
## Coverage boundary
|
||||
|
||||
What was verified, and how far the verification reaches:
|
||||
|
||||
- **Geometry, row content, row order, row count and glyph identity**, on a
|
||||
reconstructed screen, for twenty-seven scenarios — twenty-two at 100×40, the
|
||||
reconstructed screen, for forty-three scenarios — thirty-eight at 100×40, the
|
||||
three transcript arms at 100×30, the narrow boot at 60×24 and the chip row at
|
||||
58×40 — seventy-eight checkpoints in all, of which twenty-two match byte for
|
||||
byte. Both legs from one bundle and one set of boot arguments.
|
||||
- **Colour was not verified, apart from one row family.** The reconstruction is
|
||||
text. Several rows are known to differ only in which theme token they use.
|
||||
A styled capture backs all but three of the seventy-eight checkpoints and was
|
||||
read for one family only — Decision 32's cursor cell — leaving the rest
|
||||
unread.
|
||||
58×40 — one hundred thirty-one checkpoints in all, of which thirty-three match
|
||||
on their non-blank rows as captured, and seventy-three match once the
|
||||
context-file notice and the path fragments it wraps into are dropped from both
|
||||
legs. Both legs from one bundle and one set of boot arguments.
|
||||
- **Colour was read on three row families, in two terminal modes, and is unread
|
||||
elsewhere.** The reconstruction is text; colour comes from the styled capture
|
||||
beside it, which the harness writes for every checkpoint but the one taken
|
||||
after quit. Three families were read — Decision 32's cursor cell, and the
|
||||
banner wordmark and the banner panel's border. The two banner families were
|
||||
first read on different frames in different modes: the default-theme boot in
|
||||
truecolor, where only the wordmark diverged, and a named-theme boot in
|
||||
256-colour, where the border diverged too — that one is the two libraries'
|
||||
downconvert rules, recorded under Follow-ups, and the wordmark's is Decision 47. Both were then re-read in truecolor, on the default-theme boot frame and
|
||||
on a Dracula boot frame. Across the six wordmark rows of each, every one of
|
||||
the 221 cells ink paints with the ramp matches this port cell for cell, and
|
||||
the border emits the same source hex on both legs. The 25 cells per frame that
|
||||
still differ are all blank — ink emits no colour for them and this port leaves
|
||||
them at the renderer's own default, which is opaque white. That is the
|
||||
uncoloured-text divergence under Follow-ups, not a ramp difference, and it
|
||||
predates Decision 47. Every other row's colour is unread, and several rows are
|
||||
known to have differed only in which theme token they used before Decision 43.
|
||||
- **The scrollbar costs the transcript no column.** Measured at a hundred
|
||||
columns with two answer rows of 95 and 96 characters: this renderer fills 99
|
||||
columns before it breaks a row where ink fills 98, so the one-column gap sits
|
||||
@@ -1874,13 +2477,18 @@ What was verified, and how far the verification reaches:
|
||||
confirmation and then rotates the mode. The wiring is pinned by a test with
|
||||
its negative control — the intermediate mode that must release nothing — and
|
||||
the selection rule it consumes was already covered on its own.
|
||||
- **Every burst case is unit evidence only.** The acceptance harness writes one
|
||||
character per pty write on purpose, so a keystroke lands in a stdin read of its
|
||||
own, and it never pastes into a dialog input. Neither leg of the matrix can
|
||||
therefore produce the multi-key reads Decisions 33, 34 and 37 are about; those
|
||||
rest on the two dialog suites they added tests to, and on the mutations each of
|
||||
those decisions names in its own coverage paragraph, with no screen evidence
|
||||
behind any of them.
|
||||
- **Most burst cases are unit evidence only; the cursor class is not.** The
|
||||
acceptance harness writes one character per pty write on purpose, so a
|
||||
keystroke normally lands in a stdin read of its own, and it never pastes into a
|
||||
dialog input. Two scenarios now break that rule deliberately for the
|
||||
stale-cursor class: each writes three arrow keys, or two arrows and an Enter,
|
||||
in a single pty write, so the keys land in one stdin read and the handler that
|
||||
reads a closed-over cursor answers from the wrong row. Both legs are pinned
|
||||
frame by frame, on two different dialogs, and the second of them is a wrap case
|
||||
as well. What still rests on the unit suites and their mutations is everything
|
||||
else: the paste interleavings Decisions 37 and 39 describe, the retry counter's
|
||||
step guard, the answer lock and the sanitising spans of Decision 40 — none of
|
||||
which a single-channel pty write can produce.
|
||||
- **ink's own auth-wizard suite fails locally, and this change is not shown to be
|
||||
why.** That file guards nineteen tests behind a check its own comment explains —
|
||||
simulated TUI input is unreliable on slow runners — and skips them on CI and on
|
||||
@@ -1907,13 +2515,14 @@ What was verified, and how far the verification reaches:
|
||||
- **A live thought's body streams here.** ink hides it until the thought is
|
||||
expanded. Recorded rather than changed: with no scenario producing a live
|
||||
thought, a change would be unverifiable in either direction.
|
||||
- **`output.showTimestamps` still has no reader here.** ink prints a dim clock
|
||||
row above the assistant row when the setting is on. Wiring it needs a
|
||||
timestamp on the shared item type, stamped where the item is created, and a
|
||||
second source on the resume path, which rebuilds the transcript from the
|
||||
recording rather than from live items. A live-only half would itself be a
|
||||
divergence — a resumed session would show no timestamps at all — so it is
|
||||
deferred whole rather than shipped partial.
|
||||
- **The two settings Decision 48 wired are frame-verified in one scenario, at one
|
||||
size.** Both halves were read on the dedicated scenario's frames at 100×40
|
||||
against ink's: the clock row above the assistant row, and the settled card
|
||||
collapsed to its header with the expand hint. Not checked in a frame: any other
|
||||
viewport size, a resumed session — where the stamp comes from the recording
|
||||
rather than from the fold's clock — and the collapsed card's click affordance,
|
||||
which no scenario exercises, so it rests on the unit test that pins the hint's
|
||||
wording and the handler it names.
|
||||
- **Which tools actually emit a structured payload mid-execution was not
|
||||
traced.** The consolidation makes every payload available on every path, live
|
||||
chunks included, so a shell result that reports an ANSI grid while it runs now
|
||||
@@ -1945,6 +2554,37 @@ What was verified, and how far the verification reaches:
|
||||
rows to be subtracted from and they only add rows here. The two differ where ink
|
||||
would have squeezed a long result, and closing that gap means introducing a
|
||||
distribution this architecture never had.
|
||||
- **Decisions 51 through 58 rest on their unit suites and mutations, not on a
|
||||
re-run of the frame matrix over the eight.** The cosmetic divergences behind
|
||||
Decisions 51, 53 and 56 — the editor dialog's rows and hint, the auth frame's
|
||||
corners and its missing no-recommendations branch, the footer's model segment —
|
||||
were read off reconstructed frames during the sweep, and the two gates behind
|
||||
Decisions 57 and 58, `ui.mouseTracking` and screen-reader mode, were each
|
||||
confirmed on a real terminal in both directions, as their entries record. The
|
||||
rest are source-verified against ink. A screen-reader session never reaches the
|
||||
OpenTUI leg at all, since Decision 58 keeps it on ink, so that path has no
|
||||
frame the matrix could compare.
|
||||
- **The machine leg was re-run at this head and reproduced the run before it.**
|
||||
All eighty-six runs finished and none errored, and the nine idle timeouts land on
|
||||
the same three ink legs in the same counts as that run. Compared capture by
|
||||
capture, 243 of the 258 are byte-identical to it and three differ only in a
|
||||
spinner glyph or an elapsed figure. The twelve that differ in content differ on
|
||||
both arms at once and in the same way — the spinner's random phrase at two
|
||||
checkpoints, the session identifier at one, and the wall clock at three — so no
|
||||
capture attributes a change to this pass. The comparison tool's own tally
|
||||
reproduces exactly, at thirty-three identical checkpoints of one hundred
|
||||
thirty-one. The split that drops the context-file notice was not recomputed this
|
||||
round; it is a figure from the run whose frames these are.
|
||||
- **Decisions 62 through 65 have no frame behind them.** The palette revision,
|
||||
the two mirror reads and the approval dialog's remembered row were all read off
|
||||
the source against ink's, pinned by a unit case each and shown to discriminate
|
||||
by mutation. None of the four is reachable from a captured frame: the burst
|
||||
needs a paste or a held arrow inside one stdin read, which the harness cannot
|
||||
type, and the theme repaint needs a selection made mid-session. The footer's
|
||||
hint row is the same shape from the other side — it truncates segment by
|
||||
segment inside one line, which a case pins at forty-four columns, but the
|
||||
narrowest viewport the matrix runs is fifty-eight, so no frame shows a hint row
|
||||
that had to drop a segment.
|
||||
|
||||
## Follow-ups
|
||||
|
||||
@@ -1970,10 +2610,11 @@ What was verified, and how far the verification reaches:
|
||||
confirmation types — still the shared select widget — do both. Giving a click a
|
||||
meaning on the chips, on the free-text row and on the submit and cancel rows is
|
||||
its own change, as Decision 21 says; until then the dialog answers keys alone.
|
||||
- The `@` completion here asks only the file index. ink also completes
|
||||
sessions, MCP resources and extensions, and draws a category bar to switch
|
||||
between them. That is a feature gap rather than a parity defect and belongs
|
||||
in its own change.
|
||||
- Two of the four `@` completion categories Decision 49 added are unit-tested
|
||||
only. The dedicated scenario's work directory holds files and prior sessions,
|
||||
so its frames draw those two tabs and their rows; it declares no MCP server and
|
||||
installs no extension, so neither of those tabs is ever drawn and neither label
|
||||
column is ever measured on screen.
|
||||
- The shell-crawler diagnostics that print when the search binary is missing
|
||||
are now explained. The renderer library replaces the global console with a
|
||||
capture stream and folds console output into its own in-renderer console, so
|
||||
@@ -1993,14 +2634,26 @@ What was verified, and how far the verification reaches:
|
||||
tests — which asserts the constants' own sums rather than the screen. A change
|
||||
to the confirmation's geometry will therefore not fail on its own; re-deriving
|
||||
them belongs with that change.
|
||||
- The session picker's dialog does not shrink to its content. Its box is sized
|
||||
to a fixed height — the viewport less one row — and the list's scroll window is
|
||||
derived from that, so a short list still fills the full height, pushing the
|
||||
composer down and leaving blank rows below the last session. Decision 52 reused
|
||||
ink's row shape and window arithmetic but left this height fixed on purpose: it
|
||||
is the content-sizing half of the spacing family Decision 16 describes, and
|
||||
shrinking it means deriving the box from the rows shown rather than from the
|
||||
viewport, then re-deriving the reserved-row constant and row height the window
|
||||
is computed from.
|
||||
- The loading indicator has no subagent token rollup and no tokens-per-second
|
||||
segment, both of which ink shows. Its elapsed counter also restarts after a
|
||||
parked call in the shell, for the reason Decision 28 records: the row is
|
||||
mounted twice in mutually exclusive branches, so the instance that accumulated
|
||||
the time is not the one that carries on. Carrying it across the swap means
|
||||
owning the accumulator above both mounts.
|
||||
- The theme mode helpers have no production caller, so this renderer always
|
||||
paints its dark palette.
|
||||
- The dark/light mode helpers have no production caller. Decision 42 wired the
|
||||
ink theme through, so the palette does follow the active theme now; these two
|
||||
helpers are a second path to the same destination that nothing chooses between,
|
||||
and the only code that reaches them is a test fixture resetting the palette.
|
||||
Either they gain a caller or they go.
|
||||
- Two dialog list widgets remain where one would do; consolidating them touches
|
||||
numbering, colour and scroll arrows at once.
|
||||
- A line-by-line comparison against ink's component and rendering source turned
|
||||
@@ -2047,8 +2700,9 @@ What was verified, and how far the verification reaches:
|
||||
need nothing here: the theme one carries only `/theme`'s `NO_COLOR` message,
|
||||
which this renderer already routes through the dispatcher's recording wrapper
|
||||
(a theme selection adds no row on either side), the arena command's recorder
|
||||
is shared code this renderer runs the same way, and the away-summary site
|
||||
sits in a hook only ink's own container mounts.
|
||||
is shared code this renderer runs the same way, and the away-summary recorder
|
||||
now lives inside the hook this renderer mounts (Decision 55), so the recap it
|
||||
fires is recorded the same way ink's is and needs no site of its own here.
|
||||
- An auxiliary model pick is a different kind of row. ink reports a fast, voice
|
||||
or vision selection as a success item — its own glyph and colour — while every
|
||||
model outcome here, primary or auxiliary, goes through one info row, so the
|
||||
@@ -2066,22 +2720,37 @@ What was verified, and how far the verification reaches:
|
||||
scope for a sweep that measures itself against ink's behaviour as it stands;
|
||||
it is recorded here so the divergence between the two renderers is not
|
||||
re-reported as a porting gap.
|
||||
- The stale-cursor class is open everywhere except the two handlers Decision 33
|
||||
touched. Read from the source: the shared select hook, the arena dialog's model
|
||||
list, the composer's completion rows, and the auth wizard's four list steps plus
|
||||
its main and sub menus all answer from the row the last render drew. The mirror
|
||||
shape is already set by the question flow and the line editor, so this is one
|
||||
change over those widgets and their tests, not a per-dialog one — and each site
|
||||
needs its own burst case, since a mutation that fails the question dialog proves
|
||||
nothing about a list it does not touch.
|
||||
- The stale-read class is closed for cursors and open for everything beside
|
||||
them. Decision 41 put the numeric cursors behind a ref mirror — the shared
|
||||
select hook and its exposed ref, the composer's completion rows, and each
|
||||
dialog's own tab, checked set and wizard menus — Decision 63 added the
|
||||
two handlers that claim had missed, the rewind restore-option cursor and the
|
||||
permissions dialog's two free-text fields, and Decision 64 added the auth
|
||||
wizard's model-step focus and the composer's `@` category tab, so no handler
|
||||
now answers from a row or a buffer the last render drew — the wizard's review
|
||||
rows excepted, which still read the closure and are recorded beside them. What
|
||||
still does is the same root cause
|
||||
over the state that gates a branch rather than feeding a submit: a dialog's
|
||||
view name, its step, its focused zone, its search query and its editor
|
||||
preference scope are all read from the render closure at the branches that
|
||||
gate on them. Read from the source across six dialogs, not reproduced on a
|
||||
machine. The trigger is narrower than the cursor class was: it needs two keys
|
||||
of different meaning — a Tab or an Escape and an Enter — inside one stdin
|
||||
read, and ink's isomorphic code reads its own render closure the same way, so
|
||||
single-key behaviour matches. Closing it means rewriting each dialog's branch
|
||||
structure around a mirror rather than adding one beside a value, which is why
|
||||
it is recorded and not done; if it is done, per dialog, each site needs a
|
||||
batched-press case of its own, since a mutation that fails one dialog's gate
|
||||
proves nothing about another's.
|
||||
- Decision 37's latch stops a field, not a step. The wizard's list steps and its
|
||||
review row still run one branch per key. The review row applied the plan twice
|
||||
for a burst of two Enters — two writer calls, read off a scratch test at this
|
||||
head — and the protocol and endpoint rows have the same shape read from the
|
||||
source: each of their selects advances a step, so a second Enter in the same
|
||||
read skips the step between. ink's steps are the same shape, so closing this is
|
||||
not a divergence to land ahead of ink's, and it belongs with the widget pass
|
||||
above, which the same one-action-per-read latch would cover.
|
||||
not a divergence to land ahead of ink's, and it belongs with the pass over the
|
||||
dialog branch structure described above, which the same one-action-per-read
|
||||
latch would cover.
|
||||
- The latch stops keys, not a paste. Every field it settles also subscribes to the
|
||||
renderer's paste event, and that subscription never consults the latch: a read
|
||||
carrying the Enter that moves the wizard and then a bracketed paste would let
|
||||
@@ -2188,3 +2857,157 @@ What was verified, and how far the verification reaches:
|
||||
still to be picked off the screen with no user input involved — the outcome
|
||||
Decision 39's bound exists to prevent, reachable from a payload it does not
|
||||
cover.
|
||||
- Text this renderer draws with no colour of its own still carries an explicit
|
||||
foreground, where ink's carries none, and the reach is wider than blank cells.
|
||||
Read off styled captures of the same boot frame on both legs: the blank cells
|
||||
either side of the banner logo are emitted by ink with the foreground left
|
||||
unset, so the terminal's own default paints them, and by this renderer with an
|
||||
explicit white. That part is invisible as things stand — the cells are spaces
|
||||
and the background is unset on both sides — but it is not the same instruction,
|
||||
and it would show on any terminal whose default foreground is not white the
|
||||
moment a selection or an inverse run covers one of those cells. The visible
|
||||
part is a whole row class: the extension-refresh notice on that same frame is
|
||||
emitted by ink with no foreground on any of its cells and by this renderer in
|
||||
its dim token. ink's comes out unset because that theme takes its primary from
|
||||
the dark base tokens, whose foreground is the empty string, and an empty colour
|
||||
emits no instruction at all, so on a terminal whose default is lighter than the
|
||||
dim token that row reads darker here. Closing it means a way to say "no colour"
|
||||
to the renderer, which the element's foreground prop does not offer.
|
||||
- One palette key carries two jobs, which is why the foreground above cannot
|
||||
simply be left unset. The mapping's `text` is both the colour this port paints
|
||||
its own plain text with and the anchor for the markdown syntax `default` token,
|
||||
and the theme booted here leaves ink's primary empty, so the mapping falls back
|
||||
to the theme's hljs foreground to keep both alive. Honouring ink's "no colour"
|
||||
for plain text means splitting the two roles first; until then this renderer
|
||||
names a foreground for unstyled markdown that ink leaves to the terminal.
|
||||
- The no-colour theme is not honoured. ink reaches it by setting every colour in
|
||||
the set to the empty string; this renderer's mapping preserves those empty
|
||||
strings, and its theme application then filters them all out, so the palette
|
||||
keeps the built-in dark surface it was initialised with and paints a full
|
||||
colour scheme where ink paints none. The filter is right — an empty string
|
||||
means "unset", and assigning one would degrade to a colour the renderer
|
||||
guesses at — so what is missing is a branch that recognises the theme as a
|
||||
whole being empty and drops the surface with it.
|
||||
- A hex colour is downconverted to a 256-colour palette index by different rules
|
||||
on the two sides. Measured, not inferred: the banner panel's border under a
|
||||
named theme, in a 256-colour capture, lands on palette index 103 under ink and
|
||||
61 here. The same border on the default-theme boot frame, in a truecolor
|
||||
capture, carries an identical source hex on both legs — so the two sides start
|
||||
from the same colour and part company in the conversion. ink's index is exactly
|
||||
what the round-each-channel-to-six-levels formula gives for that hex; this
|
||||
renderer's conversion happens inside the renderer library's native layer, which
|
||||
is not readable from the JavaScript bundle, so its rule was not established —
|
||||
only its output. Nothing in this port chooses the index, so there is no site to
|
||||
fix; a change would have to come from the library. Recorded because it is the
|
||||
one measured colour divergence that survives every fix in this round, and
|
||||
because it will be re-found by anyone who compares the two legs on a terminal
|
||||
that is not truecolor.
|
||||
- The auto-theme module this renderer carries has no caller. Only its own test
|
||||
imports it; the theme reaches the port through the ink theme manager, which
|
||||
resolves the light/dark probe on its own side of the boundary. Either it gains
|
||||
a caller or it goes, and the same question covers the dark/light mode helpers
|
||||
recorded above.
|
||||
- The two markdown implementations break a line in different places, and put a
|
||||
list item's bullet in different columns. Both were measured on one body
|
||||
rendered through each. The width is not the cause: an unbroken run inside the
|
||||
thought body's box breaks at ninety-four columns here, which is the width
|
||||
ink's own body is given. A body of four-character words then fills eighty-nine
|
||||
of those ninety-four columns here and ninety-four under ink, and a body of
|
||||
one-character words fills ninety-three here where ninety-four would fit — both
|
||||
consistent with this side counting the space after a line's last word against
|
||||
the line, though that rule was inferred from the measurements rather than read
|
||||
out of the renderer. ink's own four lines do not follow one rule that was
|
||||
established here: they hold nineteen words at the fourth column, eighteen at
|
||||
the fifth, nineteen at the fourth and four at the fifth. Separately, ink draws
|
||||
a list item's bullet at the fifth column of the same body where this side
|
||||
draws it at the fourth. Both divergences belong to the two markdown renderers
|
||||
rather than to this port and neither has a site here to fix. Recorded because
|
||||
Decision 45 moved the thought body onto markdown, so this is where they show,
|
||||
and because any other body this port renders as markdown carries them too.
|
||||
- A single unbreakable token wraps at a different column on each side, and this is
|
||||
the one divergence that touches almost every frame the matrix captures. The
|
||||
context-file notice names a path with no space in it, so the whole path is one
|
||||
token. ink moves the token to a row of its own and then hard-breaks it at
|
||||
ninety-eight columns; this port fills the row the token started on and breaks
|
||||
at whatever column that leaves — ninety-five on the frame that was measured.
|
||||
Both are defensible and neither is a width constant that can be copied across,
|
||||
because the two sides disagree about whether a token too long for the remaining
|
||||
row is moved or broken in place. It is recorded rather than fixed for the same
|
||||
reason the markdown wrap above is: the rule lives in the layout, not in a
|
||||
number this port chooses.
|
||||
- The renderer does not repaint a cell whose new content is a space. Measured on
|
||||
ten frames, and it does not heal: the session picker's nine checkpoints and the
|
||||
preview scenario's one all carry the extension-refresh notice with box-drawing
|
||||
glyphs standing in six of its spaces — the glyphs of the banner row that sat
|
||||
there before the picker pushed it off the top. The same notice renders clean on
|
||||
the frames where the row it lands on was blank first, and the picker's own box
|
||||
on the same frame is clean throughout, so this is not a failure to clear the
|
||||
screen; it is a differential write that skips blank cells on the assumption the
|
||||
cell underneath is already blank. That assumption holds only while the
|
||||
renderer's own buffer agrees with the terminal's, and a scroll that moves rows
|
||||
up breaks the agreement for the row that was displaced. The write happens in
|
||||
the renderer library's native layer, so there is no site here to fix; recorded
|
||||
because it is visible garbage on a real terminal, and because a workaround
|
||||
would have to force a full repaint whenever the viewport scrolls.
|
||||
- A modal dialog is sized to its content here and to the rest of the viewport
|
||||
under ink. ink's approval-mode box runs from the row after the command to the
|
||||
bottom of the screen — thirty-five rows, twenty-two of them blank inside the
|
||||
box, with the key hint near the bottom — where this port draws thirteen rows and
|
||||
leaves the space above the box empty. Every row inside the two boxes is word for
|
||||
word the same, including the selected mark, so this is the box's height alone.
|
||||
It is the same family as the session picker's box not shrinking, and it is
|
||||
recorded rather than fixed for the same reason: sizing a dialog to the viewport
|
||||
is a layout decision that reaches every dialog at once.
|
||||
- Two rows truncate under ink and wrap or overflow here. The completion list's
|
||||
argument hint is clipped to an ellipsis in its own column under ink, and wraps
|
||||
to a second row aligned under the column's start here. The chip row on a narrow
|
||||
terminal clips each chip's label to an ellipsis under ink and prints the labels
|
||||
whole here, which costs the gaps between them. Neither row was compared against
|
||||
ink's truncation site, so whether the budget is a shared constant or two is not
|
||||
established.
|
||||
- The gated-server dialog's body paragraph indents its continuation row by one
|
||||
column. Both legs agree on the first row of the paragraph and on the eighty-eight
|
||||
columns of text the row carries; only the row after a break gains a leading space
|
||||
here. It is the same shape as the markdown divergences recorded above and may
|
||||
share their cause.
|
||||
- ink loses the right-hand corner of the gated-server dialog's box. That dialog is
|
||||
indented one column further than ink's other dialogs, its top and bottom borders
|
||||
run to ninety-four dashes, and the closing corner is absent from the frame — the
|
||||
box does not close. The same border on the approval-mode and session-picker
|
||||
dialogs, at one column less indent, closes on both legs. This port draws a closed
|
||||
box one column narrower, so on this row the port is the one that is right, and
|
||||
the divergence is recorded rather than matched.
|
||||
- ink has a frame where the session picker is almost entirely absent. Pressing the
|
||||
preview key unmounts the list, and the capture taken before the preview body is
|
||||
drawn holds three rows — the banner's last row, the notice and the command — with
|
||||
no list, no preview and no composer. The next checkpoint is the list again, so it
|
||||
is one frame of transition rather than a missing feature, and this port draws the
|
||||
full preview body at the same checkpoint. Recorded so the empty frame is not
|
||||
re-reported as a porting gap.
|
||||
- The stats dialog carries its own copy of the ref mirror rather than calling the
|
||||
shared hook, and rebuilds its writer on every render. Both are cosmetic: the copy
|
||||
performs the same double write, and nothing memoises on the writer's identity. It
|
||||
belongs with the consolidation of the two dialog list widgets rather than landing
|
||||
on its own.
|
||||
- The dialog chrome declares a close handler it never reads. Every caller that
|
||||
needs to close does it from its own key handler, so the prop is a dead affordance
|
||||
in the type and not on screen. That chrome's test pins the rounded outline and
|
||||
the title's colour and weight, but not the square outline the auth dialog asks
|
||||
for, so a change to the one border style that differs would pass.
|
||||
- Three branches have a shape no run reaches. The composer's unfocused border
|
||||
colour is exercised by a test alone, since the single production mount leaves the
|
||||
flag at its default. The footer's approval-mode colour dims a mode the enum does
|
||||
not declare where ink leaves the label uncoloured, and the enum is closed, so
|
||||
neither arm runs. The shared list walk types its rows as unknown and reads the
|
||||
disabled flag through a cast, because the row shapes differ per caller and a
|
||||
declared flag would trip weak-type detection on every caller that never sets it.
|
||||
- The banner gradient counts code points where the library ink renders through
|
||||
counts UTF-16 units, so a logo row holding a surrogate pair would ramp one step
|
||||
differently. The bundled logo holds none — it is box-drawing characters, all
|
||||
inside the basic plane — but the banner also accepts a custom one, so an emoji in
|
||||
a user's logo would colour differently on the two sides.
|
||||
- The clock label above an assistant row exists twice. This port calls the shared
|
||||
formatter Decision 48 added; ink's display component still formats the same
|
||||
24-hour bracketed time inline, with an identical locale call. Pointing ink at the
|
||||
shared one would edit the very file the frame evidence was captured against, so
|
||||
the second copy stays and the two are only kept equal by hand.
|
||||
@@ -214,7 +214,8 @@ modal、工具确认或补全下拉出现时把整个 footer 隐藏掉。警告
|
||||
移除。
|
||||
|
||||
只设置过某个 prop、或只移除过某个 prop 的站点不受影响,这些点被排查过而没有改
|
||||
动。没有添加渲染器级测试:该行为位于 reconciler 内部,复现它需要一个真实渲染
|
||||
动 —— 这个判定在工具卡片的标题上没有成立:折叠分支会设一个展开分支从不设的宽度,
|
||||
由决定 50 更正。没有添加渲染器级测试:该行为位于 reconciler 内部,复现它需要一个真实渲染
|
||||
表面,而 pty 矩阵已经覆盖了这一点。
|
||||
|
||||
## 决定 12 —— 字形表是引用的,不是复制的
|
||||
@@ -1279,16 +1280,464 @@ bracketed paste 就能把它停在一个值里;用户正在注视的那一格
|
||||
新条件与两处净化片段,全都要看一个按键旁边还到了什么、或者光标恰好停在哪个码点上,而没有任何
|
||||
场景造出这两件事中的任一件。与决定 39 的五项一样,这四项都只由单元测试套件兜着。
|
||||
|
||||
## 决定 41 —— 光标从 ref 里读,而列表在 ink 环绕的地方也环绕
|
||||
|
||||
本轮有两类缺陷是同一个形状穿着不同的衣服:处理函数从上一次渲染闭包住的值里作答,
|
||||
以及列表在两端停住而 ink 的继续绕回去。
|
||||
|
||||
第一类就是决定 33 的陈旧光标类,那个决定在两个处理函数上闭合了它、在其余所有地方
|
||||
留着。现在它作为一类闭合了。一个二十八行的 hook 承载这个模式 —— 一个状态值旁边放
|
||||
一面在渲染期间重新同步的 ref 镜像、一个跨渲染稳定且同步写 ref 的 setter、处理函数读
|
||||
ref —— 而共享的 select hook 把 ref 与索引一并返回,于是每个挂载它的对话框都以同一
|
||||
种方式读取。六个对话框的滚轮处理函数也读它。在状态不是光标的地方,同一面镜像被就
|
||||
写在它所影子的那个值旁边:一个已勾选集合、两个标签页索引,以及认证向导的主菜单与子
|
||||
菜单,各有自己的写入者。这一类对光标和这些都闭合了;它们旁边的视图与文本状态没有,
|
||||
已记在后续工作里。
|
||||
|
||||
第二类是导航。ink 的选取列表按行数取模步进,所以越过最后一行往下会落到第一行。本移
|
||||
植是夹紧的。那个跟随 ink 的辅助函数本来就已存在、并已在某个对话框里用着;现在它被
|
||||
用在五个对话框文件里的十二个调用点上,而它旁边那份本地夹紧副本被删掉了。它的签名从
|
||||
共享的列表项形状放宽为一个无类型的只读数组,于行是一个对话框即便行不是那个形状也能
|
||||
用它,禁用判定则在它转换出的那一行上做。有一个对话框在四步里的三步上保持夹紧,因为
|
||||
ink 在那里夹紧;它在第四步上环绕,而那一步 ink 是构成一个单选列表的。它自己的注释写
|
||||
清了哪个是哪个,于是这种混合读起来像一个决定,而不是一处疏漏。
|
||||
|
||||
以帧验证,而不只靠测试:有一个场景启动时就停在最后一个审批模式上,于是列表的光标从
|
||||
末端开始,而一次向下的按键必须落到第一行。一个夹紧的实现会产生两帧完全相同的画面,
|
||||
所以这个场景是有判别力的。两条腿在两个方向上都以同样的方式环绕,而那些帧里唯一的另
|
||||
一处不同,是决定 34 所豁免的垂直锚定。
|
||||
|
||||
## 决定 42 —— ink 的主题被应用了,启动时一次、每次选取时一次
|
||||
|
||||
本渲染器过去从不向主题层要任何东西。它的调色板在导入期由一份硬编码的暗色表面建成,
|
||||
所以设置里的具名主题、对话框里选的主题、以及明暗探测,全都是失效的:无论旁边的 ink
|
||||
画什么,本移植永远画同一组颜色。这是一个功能缺口,而不是外观缺口,而且它把下游每一
|
||||
处色差都藏住了。
|
||||
|
||||
现在有两个调用点应用当前生效的 ink 主题。一个在启动时,放在延迟的自动主题探测排空
|
||||
之后,于是它映射的主题就是 ink 最终定下的那个,首帧就已经是正确的。另一个在主题选取
|
||||
处理函数内部,在共享主题管理器提交之后,于是一次选取无需重启即生效。映射本身补上了
|
||||
它原先没有携带的五个 token:审批模式外框背后那两个减淡的状态色、shell 模式背后的符
|
||||
号色,以及 composer 外框与 banner 信息面板背后的聚焦与默认边框色。两个表面上每一个
|
||||
都有值。
|
||||
|
||||
明暗模式的那两个辅助函数仍然没有调用者。主题层是经由 ink 主题到达本移植的,而 ink
|
||||
主题自己就带着明暗,所以这两个辅助函数是通往同一目的地的第二条路,而没有任何代码在
|
||||
两者之间做选择。已记在后续工作里。
|
||||
|
||||
以帧验证,做法是把一个具名主题钉进隔离的设置目录里。在它之下,本渲染器的调色板与语
|
||||
法 token 都离开了默认主题那次运行产生的值,所以这条接线被证明确实把一个主题带了过
|
||||
来,而不只是确实跑了。banner 字标原先被当作这条证据引用,而那是错的:当时它的色标
|
||||
仍是硬编码的,两项都不跟随主题,这就是决定 47。
|
||||
|
||||
## 决定 43 —— 五处 token 级色差,以及一条没有哪个出厂主题能显示出来的分隔线
|
||||
|
||||
应用主题之后,下一层显形了:形状画对了、token 用错了的行。找到并改正了五处,每一处
|
||||
都是靠读画那一行的 ink 组件,而不是靠肉眼配色。
|
||||
|
||||
两侧的消息元数据都用了链接 token,而 ink 用的是强调 token;在默认暗色表面下这两者恰
|
||||
好同色,这就是它一直活到某个主题把它们分开为止的原因。目标卡片的颜色表是在导入期用
|
||||
调色板对象的值建起来的,所以它装着任何主题被应用之前的那份调色板,此后从不变动;现
|
||||
在它把卡片的颜色名映射到一个调色板键上,并在绘制时读调色板。footer 的状态行用的是
|
||||
减淡色,而 ink 用强调 token 画它们。footer 的审批模式标签是一种颜色,而 ink 按模式
|
||||
着色;它的提示行是作为单个拼接字符串截断的,于是把终端收窄会把那个带颜色的标签整个
|
||||
丢掉,而不是把它切断;提示现在是一列带颜色的片段,按同一份预算逐片段截断,而 ink 做
|
||||
的那两处拼接 —— 片段之间那一处,以及排队徽标之前的那个单空格 —— 都只在它们两个邻
|
||||
居确实都在场时才发出。工具卡片的状态字形过去是粗体,而 ink 的不是。
|
||||
|
||||
第六行把同一对 token 用反了:认证向导里的一条分隔线,用减淡 token 画,而 ink 用默认
|
||||
边框 token。它换过来了。与这次对调一起记下的是,没有哪个出厂主题能把它显示出来。十
|
||||
五个内置主题里有十一个不向主题构造函数传语义 token,因此走构造函数的推导,而那条推
|
||||
导把这两个 token 都读作颜色集里同一个灰字段;确实传了一份共享语义集的那四个,把这两
|
||||
个 token 读作那份语义集里同一个灰字段;省略它们的自定义主题也走推导。于是这次对调只
|
||||
在某个用户自定义主题恰好只设了两者之一时才可观察。它是一个真实的分歧和一个正确的修
|
||||
复,而在本仓库出厂的任何主题上它都不移动一帧。
|
||||
|
||||
颜色的验证方式是读两腿各自产生的那份带样式抓取。在一个真彩终端里,banner 信息面板
|
||||
外框在两侧携带同一个源十六进制值,所以决定 42 的接线确实到达了屏幕,而这五处 token
|
||||
对调确实落在了正确的值上。banner 字标在同一帧上也读过,并且不一致:两侧的色标采样
|
||||
方式不同,这就是决定 47。在一个 256 色终端里、在一个具名主题下读过的那次,外框分歧
|
||||
的原因是两侧把十六进制值降采到索引用的是不同的规则,而字标从它第二个着色列起就分歧
|
||||
—— 索引 212、218、182 对 68、104、103 —— 那是决定 47 那个源色标差异透过降采样显出
|
||||
来,而不是第二条降采样规则。继决定 32 的光标格之后,本轮清扫只在这几类行上读过颜
|
||||
色,而它是一个比其余结论所依赖的几何更窄的维度。降采样的分歧属于两个库而不属于本移
|
||||
植,已记在后续工作里。
|
||||
|
||||
## 决定 44 —— 结算的子 agent 就是一行摘要,并且没有花名册卡片
|
||||
|
||||
一个跑完的前台子 agent 过去会把它的结果正文整个画出来,而 ink 只画一行:一个字形、
|
||||
agent 的名字,以及把描述与它的工具数、它自己的子 agent 数、耗时和输出 token 数拼在
|
||||
一起,未完成时再加上终止原因。本移植既画正文又画卡片,于是一个已结算的 agent 占着
|
||||
一块 ink 早已交还的屏幕。
|
||||
|
||||
摘要在事件适配器里推导,用的是 ink 自己那个辅助函数所用的同一批片段,并且只对到达
|
||||
三种结算状态之一的运行推导 —— 一个在跑的子 agent 完全不画摘要。统计它的子 agent 数
|
||||
以 ink 的工具消息所用的同一个名字集合为键,而那个集合被移到一个共享模块里,于是两侧
|
||||
无法漂移。这一行在屏幕上带三段颜色,与 ink 一致:字形用色调色,名字用主文本色并加
|
||||
粗,其余减淡并按共享的结果长度规则截断。
|
||||
|
||||
花名册卡片随之而去。任务结束事件收窄为一个标识符,两个模型层现在都在它到达时把那张
|
||||
卡片剪掉,于是结算那一行就是摘要、别无其他。卡片的完成标志与它的统计字符串过去也只
|
||||
被读来构造摘要现在构造的那一行,而它们旁边那个本地 token 格式化函数有一个共享的等价
|
||||
物,所以三者都被删掉,而不是留着没人引用。
|
||||
|
||||
## 决定 45 —— 思考正文是 markdown,而它的标签带着流逝时间
|
||||
|
||||
一个展开的思考过去把它的文本印成一段减淡的连续文本。ink 把同一段文本经 markdown 组
|
||||
件渲染、并用次级文本色,这会把纯文本*和*标题都画成那个颜色,同时让行内代码与链接保
|
||||
留它们自己的颜色。于是一个思考里的标题在这里读作正文、在那里读作标题,而一个链接在
|
||||
这里彻底丢了颜色。
|
||||
|
||||
正文现在是 markdown,放在一个由状态字形所占的那同样两列缩进的盒子里,于是正文拿到
|
||||
的是 ink 自己的宽度,而不是跑到那个沟槽底下。这个宽度是量出来的,不是推出来的:盒子
|
||||
里一段不断开的连续字符在第 94 列断开,而这正是同一终端宽度下 ink 的正文被给到的宽
|
||||
度。盒子决定不了的是 markdown 在一段普通散文里把行断在哪里,而那里两侧不同;这处不同
|
||||
记在后续工作里。因为渲染器自己的行内文本 token 会压过一个元素的前景色,一个减淡的正
|
||||
文需要它自己的 token 表:在每次应用主题时,就在语法样式旁边建一份,只把行内文本
|
||||
token 与标题那几个改写成减淡色,代码与链接的 token 不动。一个省略了行内 token 的主
|
||||
题会让它继续被省略,于是无颜色主题仍然像 ink 一样让那段文本不着色。它改写用的那个减
|
||||
淡色是在调色板赋值之后读的,不是之前。
|
||||
|
||||
同一行上还有两件小事。它的折叠提示缺了说明这一行可以点击的那几个词,而 ink 会印出
|
||||
来;它读取的那个可点击标志在唯一的调用点上被硬编码为关,所以它被去掉了,而不是被携
|
||||
带着。还有一个仍在流式的思考没有耗时,所以它的挂起标签印出来时不带 ink 在每次
|
||||
渲染都推导的那个流逝时间;现在这个标签在没有被盖上耗时戳时,从开始时间戳推导它。
|
||||
|
||||
## 决定 46 —— 三个对话框改建在共享外框上,另外两帧被改正
|
||||
|
||||
审批模式、思考强度与输出风格这三个对话框各自带着自己的本地外框:自己的边框样式、自
|
||||
己的内边距、自己的标题颜色、自己的底栏提示位置,以及自己手写的行文本。每一处都分歧,
|
||||
而没有一处是靠逐对话框修复能保持修好的。三个都改建在其余部分早已在用的共享对话框外
|
||||
框、列表与底栏提示组件上,行文本由 ink 自己那些对话框所调用的同一批显示名与描述辅助
|
||||
函数构造 —— 于是带编号的行现在读起来与 ink 一致,而将来对共享外框的一次改动也能到
|
||||
达它们。
|
||||
|
||||
思考强度对话框还补上了 ink 那个用于设定作用域的步骤,由 Tab 抵达,并带着 ink 在工作
|
||||
区值覆盖了正在改的那个值时印出的工作区优先告警。它在所配置强度对当前模型不可用时的那
|
||||
条说明,现在带着 ink 的两个分支,而不是一个。
|
||||
|
||||
旁边还有两帧被改正。shell 对话框画的是方角边框、强调色标题,以及一条 ink 不印的关闭
|
||||
提示,两侧还各花了一列 ink 的盒子不花的内边距;它现在画圆角边框、用默认边框色、标题
|
||||
用加粗的主色、没有关闭提示,并采用 ink 自己对恢复会话的措辞。认证向导的外框比 ink 多
|
||||
花一列内边距、印了一条 ink 不印的关闭提示,而它的分隔线短了二十四列;三处都改正了,
|
||||
而它的链接行像 ink 画的那样带下划线并减淡。
|
||||
|
||||
机器腿在这个 head 上重跑了一遍,并且先做了加宽:为思考卡片、结算的子 agent、认证向
|
||||
导、两个陈旧光标突发、环绕列表与一个具名主题新增了七个场景,于是矩阵现在装着三十四
|
||||
个场景、六十八次运行,没有一次报错。九十七个检查点里,二十六个的非空内容行完全一致,
|
||||
六十九个分歧;把两个抽样检查点 —— spinner 的短语轮换与流中指示符,它们装的是流逝的
|
||||
时间和每次 tick 挑出的短语 —— 单独计开之后,分歧带着 365 行只有 ink 画、357 行只有
|
||||
本移植画。于是完全一致的检查点数读作二十六,而上一个 head 读作十九。
|
||||
|
||||
与上一个 head 相比,把 banner 印出的版本号与 footer 印出的分支名都归一化掉之后,两
|
||||
次运行共有的 152 个普通抓取里有 143 个逐字节一致。不一致的九个,分成上一次运行点名的
|
||||
那同样四族瞬时项:三个在 `@` 补全场景的 ink 腿上,这一次运行它印出了归档那次没有印的
|
||||
扩展刷新提示;另外六个分布在流中指示符、`/stats` 与按住短语那一行上,两条腿各一份。
|
||||
这九个里没有一个属于本轮改动碰到的抓取。
|
||||
|
||||
六十九处分歧里多数是决定 34 所豁免的垂直锚定、记在后续工作里的超长 token 换行,或者
|
||||
一张结算卡片印出的流逝时间。七个新场景的表现符合预期:子 agent 的摘要行在两腿上连它
|
||||
的分隔符都一致,而环绕列表的三个检查点只在块放在哪里上不同,每一帧里光标都在列表的同
|
||||
一行上 —— 从最后一行往下一次按键在两腿都落到第一行,这正是那个场景被写出来要做的判
|
||||
别。思考卡片的两个检查点不一致,而其中不属于超长 token 换行的那部分,就是记在后续工
|
||||
作里的 markdown 断行点差异。
|
||||
|
||||
## 决定 47 —— 字标渐变按行采样,色标取自主题的语义色标
|
||||
|
||||
banner 的字标在两腿上都带渐变,而两者不一致。把带样式抓取按格读遍字标的六行 —— 221
|
||||
个着色格 —— 表明分歧有两个彼此独立的原因,缺了任一个另一个都显不出来。
|
||||
|
||||
第一个是用哪些颜色。本移植硬编码了基础亮色与暗色颜色集所声明的那份色标。ink 是从当
|
||||
前主题的语义 token 上读它的,候选项与顺序都相同:主题自己声明的渐变(当它声明了一份
|
||||
可用的时候),否则是次级、链接与强调这三个文本色,否则根本没有渐变 —— 这种情况下
|
||||
ink 把字标画成不着色,而不是抛错。十五个内置主题里有十一个走构造函数的推导,因此确实
|
||||
带着自己的色标。传了一份共享语义集的那四个 —— 两个 Qwen 主题与两个 ANSI 主题 —— 继
|
||||
承的是基础色标,它们自己声明的渐变根本到不了 banner。本移植现在从 ink 主题按这同一组
|
||||
候选项推导色标,用解析调色板的同一方式解析每个色标,并把幸存者少于两个当作没有色标。
|
||||
|
||||
第二个是色标怎么采样。ink 的渐变组件包住了渲染器的按节点文本变换,而那个变换每个排
|
||||
版出来的行只跑一次,所以字标的每一行都在自己宽度对应的步数上建一份自己的色标。两个
|
||||
色标之间的各步并不是普通插值:它背后的库先给每段播下一个对步数四舍五入的份额,再重
|
||||
新配平直到各段之和等于步数,然后每段从自己的起始色出发逐步推进。本移植六行共用一份
|
||||
色标,并且按最宽那一行的普通分数做插值。两个选择都错,而且错法不同:共用色标把中间
|
||||
那个色标放在每一行的同一列上,而 ink 在 36 列的首行上把它放得比 37 列的那几行早一
|
||||
列;普通分数则让较短的那行差一步才到最后一个色标。按行采样并用那个库自己的分段算法,
|
||||
能逐格复现 ink 抓取里全部 221 个格;原先那个公式复现其中 26 个。
|
||||
|
||||
这段算法是重新实现而不是引入的 —— 约二十五行的量 —— 因为定义它的那个库只是作为 ink
|
||||
所用组件的传递依赖到达本包,直接依赖它就意味着要声明它。它由测试钉住,用的是从抓取里
|
||||
读出的十六进制值,两种行宽都覆盖;并且已经反证过这些测试在原先那个公式下会失败。
|
||||
|
||||
## 决定 48 —— ink 会读的两个设置项,在这里没有读取点
|
||||
|
||||
一轮针对「会改变 ink 画什么」的设置项排查发现两个:本渲染器从同一份合并后的配置里收下
|
||||
了它们,然后一次也没看过 —— assistant 行上方那行时钟,以及把一次工具调用折叠成只剩表头
|
||||
的那个开关。两者都靠读遍本包里对每个键的每一处引用来确认:唯一的读取点是 ink 自己的历
|
||||
史展示。一个没有读取点的设置项比一个缺失的功能更糟,因为配置界面提供它,看上去还像是生
|
||||
效了。
|
||||
|
||||
时钟需要在共享的条目类型上加一个时间,并且这个时间有两个来源。进行中的一轮没有已记录的
|
||||
时间可读,所以时间戳是在这一块被打开时打上的,用的是折叠自己的时钟;之后追加到同一个流
|
||||
式块的分片不动这个时间戳 —— 一条消息,一个时间戳。恢复出来的会话改为从录制重建
|
||||
transcript,那里每条 assistant 记录带着自己的时间;而一条记录里有好几个文本片段,所以
|
||||
只有其中第一个文本运行被打上时间戳,开头的一段推理不会把这个时间戳消耗掉。只上实时的那
|
||||
一半正是后续工作条目警告过的分歧:一个恢复出来的会话会完全不显示时间戳。标签本身是 ink
|
||||
的,逐字一致 —— 一个 24 小时制、补零、方括号包起来的时钟,取自英文 locale,这正是
|
||||
`23:00:00` 不会在一台 locale 不同的宿主机上被渲染成 12 小时制的原因。
|
||||
|
||||
折叠用的是 ink 的规则,带着 ink 的四个例外。只有在这个设置关着、并且这张卡片没有被点开
|
||||
过、并且应用级的全量详情开关没有把它按住、并且这次调用不是还在等一个答复的时候,卡片才
|
||||
折叠。最后那个例外才是要紧的那个:卡片是唯一承载着待批准载荷的表面,所以一张折叠的卡片
|
||||
等于让用户去批准一个他读不到的东西。ink 按卡片可点与否在两种提示之间做选择,而本渲染器
|
||||
除屏幕阅读器模式外都开着鼠标追踪,于是取可点的那一支,只提点击 —— 这与思考卡的提示早已
|
||||
做出的选择是同一个。
|
||||
|
||||
两半都由测试钉住,并且已经反证过它们在原先的行为下会失败:去掉设置项这道门、去掉待答
|
||||
复这个例外、给每一个文本运行都打时间戳而不是只给第一个、读一个 12 小时制的时钟、以及在
|
||||
只有一种可供性生效的地方把两种都说进提示里 —— 每一处都打破断言,且只打破那些断言。
|
||||
|
||||
## 决定 49 —— `@` 补全问遍 ink 会问的每一个来源,并排在 ink 的标签页后面
|
||||
|
||||
本渲染器的 `@` 补全只问文件索引,别的什么都不问。ink 还会补全当前项目过往的会话、MCP
|
||||
服务器及其资源、以及已安装的扩展;并且在超过一类候选在场时,画一条标签栏在它们之间切
|
||||
换。
|
||||
|
||||
搜索本身是复用而不是重新实现的。ink 的补全 hook 收一个模式与两个回调,其余全都归它管:
|
||||
爬虫的生命周期、承认一次搜索「慢了」之前的那段短延时、取消一次已被更新的按键取代的搜
|
||||
索,以及它自己写成不变量的合并顺序 —— 会话在每一条路径上都是最后追加的。重新实现它就
|
||||
意味着在第二个地方把这条不变量抄一遍,而这个 hook 里没有任何与渲染器相关的东西。没有
|
||||
复用的是 ink 的导航状态:它自己保存高亮下标与可见窗口,而本 composer 两者都已经有了,
|
||||
并且是按「在一次渲染里处理完的一批按键」下也能成立来建的。所以只移植了分类规则 —— 固
|
||||
定的标签顺序、一份结果集所蕴含的标签集合、过滤、环绕步进,以及当一份更新的结果集不再包
|
||||
含屏幕上那一类时退回第一个标签。最后这一条是在渲染期间推导出来的,而不是事后再纠正,这
|
||||
样省一趟渲染,而看上去完全一样。
|
||||
|
||||
复用这个 hook 暴露出一个本地搜索从来没有过的隐患。离开 `@` —— 缓冲区被清空、改为输入
|
||||
斜杠 —— 会让这个共享 hook 派发一次重置,而那次重置的发布落在同一次按键已经把斜杠结果
|
||||
发布出去的那趟渲染之后,把它们抹掉。所以两个被转发出去的回调都以本 composer 实际所处的
|
||||
模式为门,而这个模式是在处理按键时同步更新的。这道门还为 `@` 关上了一个 ink 自己仍然有
|
||||
的隐患:一次在缓冲区被清空之后才解析完的搜索,再也不能在空 composer 上重新填出一个下拉
|
||||
并把下一次 Enter 抢走。shell 模式那个按键上的守卫留着,因为斜杠参数补全仍然是本地驱动
|
||||
的,仍然可能与之竞态。
|
||||
|
||||
画法上有两处跟 ink 而不是跟本渲染器的斜杠列表:标签栏只在它自己出现的时候才拥有裸的左
|
||||
右箭头,并且把每一个修饰键都钉成关,好让按词移动与任何终端绑定仍然到达缓冲区。以及在
|
||||
mention 模式下,只有带描述的那些行共享标签列 —— 一条裸路径占满整行宽度 —— 而在斜杠模
|
||||
式下每一行都共享一列。标签文案是从 ink 自己的列表组件里导出的,而不是抄一份,这样两个
|
||||
渲染器不可能在一处翻译上各自漂移。
|
||||
|
||||
## 决定 50 —— 卡片标题是一个换行块,而两个状态共用一个树形
|
||||
|
||||
对决定 48 接上的那次折叠做帧验证时,翻出了工具卡片标题上的两个缺陷,其中一个是只有帧才
|
||||
能显示的回归。
|
||||
|
||||
第一个是折叠态卡片自己的树形。折叠与展开返回的结构不同,而本渲染器的 React 绑定按位置
|
||||
与类型复用子节点、并且从不清除一次重渲染丢弃的 prop —— 也就是决定 11 点名的那个隐患,
|
||||
而且是它那次普查判定为不受影响的点位之一:折叠分支会设一个展开分支从不设的宽度,正是
|
||||
移除那一种情形。于是承载两列状态字形的那个盒子被复用到了展开态标题行所用的盒子上,保留着当初给它的宽度,
|
||||
展开的卡片只画出字形与工具名的第一个字母,其余全被裁掉。两个状态现在返回同一个树形:同
|
||||
一条标题行,折叠时把名字与提示分成两段、展开时合成一个换行块,卡片正文在折叠时干脆不出
|
||||
现。有一个测试钉住 jsdom 能看到的那部分 —— 切换前后标题行是同一个元素实例,而字形那个盒
|
||||
子是唯一一个带宽度的后代 —— 两个树形再分开一次它就会失败。宽度残留本身是单元测试运行时
|
||||
无法复现的渲染器行为,它是从终端上读出来的,修复之后也用同样的方式确认它消失了,包括经
|
||||
过一次 resize。
|
||||
|
||||
第二个是展开态标题自己的布局。它原本是几个并排的文本节点:名字、描述、标记一个等待中调
|
||||
用的箭头。flex 兄弟节点复现不出 ink 画出来的东西,因为 ink 把名字与描述当成一个换行块,
|
||||
其宽度是标题自己的内宽 —— 卡片宽度减去状态字形占掉的那两列。由此带来三个后果,每一个都
|
||||
在帧里看得见:描述的前导空格丢了,于是名字与命令连在一起;续行缩进八列,而 ink 缩进四
|
||||
列,因为兄弟节点是从名字之后开始,而不是从名字下方开始;箭头落在这一行结束的地方,而不
|
||||
是块的最后一个字符之后。标题现在是一个承载内联样式段的文本节点 —— 名字加粗,描述与箭头
|
||||
暗色,箭头用 ink 给它的主色 —— 于是换行由渲染器自己决定,几何也就对上了。块的宽度与截断
|
||||
helper 用来算行数上限的那个值同源,这也是上限与绘制现在能一致的原因。
|
||||
|
||||
还剩两条残差,它们记在各自的条目下而不在这里:名字与描述之间那一格在 ink 里不着色、在这
|
||||
个端口里着暗色,也就是未着色文本那条分歧;以及比块还长的 token 在这里仍然断得比 ink 早,
|
||||
也就是长 token 换行。
|
||||
|
||||
带时间戳的助手行拿到了同一个解法,因为它的两个树形以同样的方式相异,而在两者之间做选择
|
||||
的那个设置可以在对话框里改、且不需要重启 —— 于是一条已经画在屏幕上的行会在会话中途换树
|
||||
形。它的分支现在各带一个 key,有一个测试钉住要紧的那部分:翻转之后未打戳的那一行是消失了
|
||||
而不是被就地改造,而这条断言在 key 被拿掉时就会失败。
|
||||
|
||||
机器腿在这个 head 上重跑了一遍,先加宽了五个场景:时间戳与折叠卡片同时在场、`@` 的分类、
|
||||
离开自动 recap、编辑器对话框,以及会话选择器的 Space 预览。矩阵现在有四十三个场景与八十六次
|
||||
运行 —— 三十八个在 100×40、三个转录臂在 100×30、窄启动在 60×24、chip 行在 58×40 —— 没有
|
||||
一次报错。有九次以 idle 超时结束、而不是落在一帧稳定的画面上,而这九次全是一直都会超时的
|
||||
那几条腿:ink 上认证向导的模型列表(它的 spinner 永远不进入 idle),以及两个多问题臂。
|
||||
|
||||
一百三十一个检查点里,三十三条的非空内容行完全一致。其余九十八条由一个家族主导:上下文文件
|
||||
通知里那条路径长到本身就是一个不可断的 token,两条腿在不同的列上断它,于是几乎每一张画出这条
|
||||
通知的帧都画得不一样。把通知与它折行成的那些路径碎片从两条腿都丢掉之后,剩下七十三条一致、
|
||||
五十八条分歧,其中两条是采样型检查点 —— spinner 的短语轮转与流式中途的指示器。把这两条分开
|
||||
计之后,其余携带 258 条只有 ink 画的行与 288 条只有本端口画的行。
|
||||
|
||||
这五十六条按主因落在十一个家族里,其中四个是本轮新出现的。九条是非确定性的,任何修复都闭不
|
||||
掉:两个采样型检查点、两条单帧的 spinner 短语、时间戳场景那三张时钟自己走了的帧,以及子
|
||||
agent 那两帧上的耗时毫秒数。十九条是决定 26 —— 本端口还显示着 ink 已经滚出去的 banner,于是
|
||||
同样的内容落在不同的行上。十条带着一个更早的轮次没有显示过的缺陷:通知行留着它顶掉的那条
|
||||
banner 行的字形,因为渲染器从不重绘一个「新内容是空格」的格子。四条是一个不拉伸的对话框 ——
|
||||
ink 把它的审批模式框撑到视口剩下的全部高度、并在框内填满空行,本端口按内容撑高,而框内每一行
|
||||
都逐字相同。四条是卡片标题的长参数折行,四条是行尾那个滚动条字形,两条是一个滚动偏移量,
|
||||
两条是同一行出现的顺序不同,两条是两条腿对截断的分歧,一条是展开的思考正文的缩进,一条是
|
||||
受门禁的服务器对话框里段落续行的缩进。
|
||||
|
||||
垂直方向的对比不再报告,因为决定 26 已经让它失去意义:本端口把 composer 钉在视口底部,而 ink
|
||||
钉在顶部,于是 129 对重建帧里有 128 对只在位置上有别。内容行才是那个决定之后仍然成立的对比。
|
||||
|
||||
## 决定 51 —— 编辑器对话框在用户看得见的地方报告一次失败的保存,而它的两个列表就是那个共用的带序号窗口
|
||||
|
||||
`/editor` 经由共用的 settings 对象持久化一个偏好,而那次写入是可能抛错的 —— 一个只读文件、一个进程够不到的作用域。ink 会接住这个失败并把它打印在对话框内,于是用户读得到为什么什么都没变,而对话框仍然开着、可以换一个作用域再试。本渲染器把它吞掉了:catch 在出错路径上和成功路径上一样把对话框关掉,于是一次失败的保存与一次成功的保存无从分辨,而那个偏好就悄无声息地没有动。写入现在改为记下一个错误字符串而不是关闭,字符串渲染在外框上方的红色里,对话框留在挂载状态。有一个测试驱动一个会抛错的 setter,并断言两半 —— 屏幕上的消息与仍然开着的外框 —— 当 catch 退回到「关闭」时它就会失败。
|
||||
|
||||
对话框里那两个列表原本是手画的行,这让它们丢掉了这里其余每个列表都有的序号列与滚动窗口。两者现在都走共用的 select hook 与控件,于是编辑器步骤与 Apply-To 步骤给自己的行编号、把它们开窗而不是撑出外框、并跨过被禁用的那一行而不是落在它上面。这个控件就是确认对话框用的那一个,所以编号、高亮与滚轮都与本移植的其余部分一致,而不是一次性的写法;编辑器列表还会在用户从 Apply-To 回来时,把光标重新落到所选作用域已存的偏好上,这正是 ink 给自己那份列表键控的方式。
|
||||
|
||||
对话框原本在两列之上画了一行自己的标题,而它底部的提示会折到第二行,ink 则把它截断在左列之内。标题行没有了 —— 两个列头现在就是顶行,与 ink 一致 —— 提示被裁到该列自己的预算:内容宽度的 45% 再减去两列内边距,这正是 ink 放它的那个 truncate 盒子。测试分别钉住两半:对话框没有自己的 chrome 标题行,且提示被裁进左列、带单个省略号,而不是折行。
|
||||
|
||||
## 决定 52 —— `/resume` 与 `/delete` 共用 ink 的会话选择器,而删除提交一组勾选
|
||||
|
||||
`/resume` 与 `/delete` 各画各的会话行,两者都不匹配 ink 为二者挂载的那个选择器。这些行现在是同一个组件,建在 ink 的 SessionPicker 所用的同一批部件之上、而不是另起炉灶:共用的过滤器与分页大小、相对时间与消息数格式化器、截断 helper,以及带可打印字符判定的搜索输入 hook。一行携带两列的光标标记 —— 在窗口边缘处是一个上或下箭头 —— 被裁到自己预算的标题,以及一条按 ink 顺序排出的元信息行:相对时间、消息数、分支与禁用提示。可见窗口由终端高度以 ink 派生它的同一方式派生,所以列表会滚动,而不是冲出外框。
|
||||
|
||||
删除还缺 ink 的批量路径。它现在以多选打开来挂载选择器,并为单个与批量两种情形复用 ink 自己的删除命令,于是「不许删当前会话」的守卫、互斥锁,以及每一条结果消息都是共用的,而不是重新措辞。Enter 提交那组勾选,按完整列表排序、并过滤到启用的行,所以一次缩窄视图的搜索绝不会悄悄丢掉打字之前做出的勾选;当前会话被就地禁用而非过滤掉,与 ink 一致。resume 保留单选选择器,并在 `/resume <title>` 匹配到多于一个会话时带上该命令预过滤的列表。光标与勾选集合都坐在 ref 镜像之后,于是一次读取里的一串 Space 与方向键提交的是用户真正勾上的那组,而不是上一次渲染画出的那组。
|
||||
|
||||
结构与批量路径由针对渲染行的测试钉住 —— 两行形状、未知计数那一例、勾选框提交、被禁用的当前会话、多选关闭时藏起的勾选、窗口边缘标记与分页 —— 每一个都被证明会对着先前手画的行失败。选择器自己的高度仍然是固定的、而非按内容定尺寸;那记在后续工作下、而非在这里改动,因为它属于决定 16 描述的那个间距家族。
|
||||
|
||||
## 决定 53 —— 认证向导的模型步骤以颜色聚焦、以八行为窗,装在 ink 的方角外框里
|
||||
|
||||
引导向导的模型步骤原本在被聚焦那一行旁画一个 `›` 光标字形、一次列出每一个推荐模型、且没有搜索框。ink 的模型步骤这三样都不做:它只以颜色传达聚焦、把列表开窗到八行、并放一个一次 Tab 之遥的搜索框,随用户打字过滤推荐项。这个步骤现在对上了 —— 字形没有了,被聚焦的行是绿色,而一个已勾但未聚焦的行取 accent 色、一个普通行取正文色;列表是过滤后模型的一个切片,上限为 ink 共用的那个八行常量,并随聚焦移动而滚动;搜索框用 ink 自己的选项标签与搜索文本 helper,所以各行读起来一致、也不可能在一处翻译上各自漂移。那些常量与 helper 是从 ink 的 provider-setup 模块引入的,而不是抄一份。
|
||||
|
||||
对话框的外框原本是圆角、和这里其余每个对话框一样,但 ink 用单线边框给它的认证对话框描框,所以向导现在向共用的 shell 要方角、而把本移植的其余部分留成圆角。它还缺 ink 为「一个不带任何推荐模型的 provider」准备的那个分支:在列表本会为空之处,ink 把推荐与搜索整个丢掉,改而在一个自由文本框里要逗号分隔的模型 ID,并以该 provider 自己的默认值作示例。这个分支现在画出来了,而另一个「搜索为空」的结果 —— 模型存在但没有一个匹配查询 —— 用 ink 的措辞说出来,而不是显示一个空列表。
|
||||
|
||||
测试钉住 jsdom 看得见的部分:为一个不带模型的 provider 画出无推荐分支、空提交时浮出模型 ID 错误、搜索框在一次 Tab 之遥过滤推荐列表、以及自定义 ID 框保留它自己的光标。这个步骤本就有的那两个连击用例 —— 一次按住的 Space 把每一跳都应用到同一个勾选框、以及一次 Enter 提交在同一次读取里做出的那一勾 —— 在「只靠颜色聚焦」之下仍然通过,而这正是字形回来、或窗口不再滚动时会失败之处。
|
||||
|
||||
## 决定 54 —— 描述型列表把描述堆在标签之下,并用绿色标记被选中的那一行
|
||||
|
||||
有三个对话框画一种列表,其行携带一个标签、下面跟一段描述 —— 认证向导的那几个 radio 步骤,以及 arena 的停止与选取对话框。三者都把描述多缩进两列、越过它自己的标签,因为描述坐在一个从标记之后开始的兄弟节点里、而不是在标签之下;三者也都用正文色标记被选中的行,而 ink 用绿色。每一行现在是一个两列的盒子:一个固定两列、装着 `›` 或一个空格的标记,旁边一个单一的、增长的列,把标签与描述堆在一起,于是描述与标签对齐、而不是越过它。被选中行的标记与标签取 ink 所用的那个 success 绿,而未选中的行保持正文色、描述为暗色。
|
||||
|
||||
这处改动是从三个对话框自己测试里渲染出的行读出来的,那些测试拿描述的列去对比标签的列、并断言被选中行的颜色。行间距没有动 —— 这是缩进与颜色,而不是行与行之间的节奏。
|
||||
|
||||
## 决定 55 —— 离开自动 recap 之所以会触发,是因为入口挂载了 ink 容器所挂的那个 hook
|
||||
|
||||
ink 会在用户回到一个被闲置超过阈值的会话时触发一次 recap:它的容器挂载一个 hook,盯着聚焦与闲置两个边沿,并在回来时追加一行摘要、把它记录下来。本渲染器从不挂载那个 hook,所以离开自动 recap 从不触发 —— 设置存在、hook 存在,而两者之间什么都没有跑。这个 hook 现在挂在入口里,而那正是实时 transcript 与「流式→闲置」边沿已经在的地方,所以它的门禁读得到它需要的行、无需第二份副本。聚焦来自本渲染器自己的一个新 hook:@opentui/core 已经解析终端的 focus 与 blur 事件,但没有任何东西把焦点上报打开,所以这个 hook 为 OpenTUI 这条腿拥有 `?1004` 模式、以聚焦为起点、并在任何一次按键上重新主张聚焦 —— 这是 ink 自己的 tmux 变通,因为一个不转发焦点事件的会话否则会永远停在 blur 上。任何时候只挂载一条腿,所以两者绝不会都去写这个模式。
|
||||
|
||||
hook 返回的那次 recap 落成它自己的一个 transcript 条目,它会先停住一个正在流式的 assistant 块、再追加摘要,所以它不会拼接进一个仍在进行中的回合。这个 hook 自己把那次 recap 记录下来 —— 一个 `/recap` 命令的 result 阶段、绝不是 invocation 阶段 —— 与手动命令一致,于是自动触发的那次摘要以同样的方式到达会话日志。一个恢复出来的会话是否会重画它,是后续工作下那条已经记着的 result 阶段重放缺口,那条覆盖每一个 slash 命令的 result 行、而不单是这一条。
|
||||
|
||||
测试覆盖聚焦 hook 的各个边沿 —— 挂载时写入、卸载时清除的那个模式,focus 与 blur 事件,按键重新主张,detach,以及一个会抛错的 stdout —— 还有 recap 条目向 transcript 的投影。hook 自己的触发是 ink 的共用代码、在那边被测;这个决定加的是挂载、聚焦来源与那个条目,各自在这里钉住。
|
||||
|
||||
## 决定 56 —— footer 的模型段带上 reasoning effort
|
||||
|
||||
ink 的状态行渲染一个 model-with-reasoning 预设项,所以它的模型段在名字旁边带上 reasoning effort —— 或者在模型没有时带 `reasoning off`。本渲染器的 footer 打印的是裸模型 id、丢掉了 effort。这一段现在走 ink 预设所用的同一个共用格式化器,它剥掉一个前导的 provider 标签、在有 effort 时追加它、并在 reasoning 被显式关掉时说 `reasoning off`。effort 来自本移植其余部分所读的同一个显示 helper,所以 footer 与任何其他点名模型的面都一致。
|
||||
|
||||
有一个测试拿这一段去对比预设的输出 —— 一个 reasoning off 的模型读作它的名字后跟 `reasoning off` —— 而这正是 footer 退回裸 id 时会失败之处。这处分歧最初是从一张重建帧上读出来的,那时两条腿的模型段恰好差一个 effort 后缀。
|
||||
|
||||
## 决定 57 —— 指针跟随 `ui.mouseTracking`,两条展开提示也是
|
||||
|
||||
ink 在两处读 `ui.mouseTracking`:它决定渲染器到底抓不抓指针,也决定一张折叠卡片显示两条展开提示里的哪一条 —— 鼠标生效时是 `click to expand`,不生效时是 `ctrl+o to expand`。本渲染器两处都不读。它创建渲染器时指针永远开着,而两条展开提示 —— 折叠工具卡的与思考卡的 —— 都把 clickable 分支硬编码下来,所以它们即便在 `ui.mouseTracking: false` 之下也宣传一次点击,而这个设置在这里根本没有读取点。现在两处都读它:设置关掉时渲染器创建时不抓指针,于是右键菜单与 OSC 8 链接点击被交还给终端、和 ink 一样;并且一个活的鼠标标志被接进 transcript,好让每条提示取匹配的那个分支。
|
||||
|
||||
提示跟随 ink 自己的选择、而不是一条新规则 —— 指针生效时可点、不生效时只留按键 —— 这正是思考卡的提示本就做出的那个判断。这个标志默认为生效,所以默认设置在屏幕上什么都不改;把它关掉会一次从两条提示和指针上同时拿掉点击可供性。决定 48 是在「鼠标在这里除了屏幕阅读器之下都是开的」这个假设下取 clickable 分支的;这个决定把那个设置做成该分支上的第二道门,而决定 58 补上屏幕阅读器那条腿。
|
||||
|
||||
两个方向都在真机终端上确认过:一个把设置关掉的专门场景在两条腿上都不显示点击可供性 —— 折叠卡与思考卡都读作 `ctrl+o to expand` —— 而同一个 bundle 在默认设置下仍然提供点击。单元测试在它旁边钉住接线:设置关掉时 transcript 收到的标志为 false,且每条提示的文案跟随它。
|
||||
|
||||
## 决定 58 —— 屏幕阅读器会话留在 ink 上
|
||||
|
||||
屏幕阅读器模式需要主屏幕上的纯文本、只追加、无鼠标的输出,好让屏幕阅读器在每一行落下时读它一次。本渲染器没有为此的路径:它在备用屏幕上原地绘制、带差分重绘和一个活指针,而这些屏幕阅读器解析不了。在这个标志下服务 OpenTUI 会把原地重绘塞给这样一个用户,那是比没用还糟。所以渲染器选择门禁现在把屏幕阅读器模式当成第二项「仅 ink」的能力,与它本就有的运行时探测并列:标志打开时门禁留住 ink,而在 strict 模式下它抛错、而不是静默回退。
|
||||
|
||||
这道检查坐在运行时探测之前、并压过它,所以一个跑在无法初始化原生 FFI 的运行时上的屏幕阅读器会话,仍然报屏幕阅读器那个原因、而不是 FFI 那个 —— 是这项能力、而不是平台,才是 ink 在服务的原因。入口把配置好的屏幕阅读器标志传进门禁,所以这是一个活的开关、而不是一个死参数。本渲染器带着的那两个纯文本与屏幕阅读器策略模块仍然没接线:它们只被自己的测试引入,而把它们穿进一条渲染路径是后续工作、不是这次改动。这个决定就是那道门禁,在它们接好之前把这类会话留在 ink 上。
|
||||
|
||||
测试钉住回退与它的原因两者,而一个把屏幕阅读器检查挪到运行时探测之后的变异,会让「在不受支持的运行时上屏幕阅读器原因胜出」那个用例失败。在真机上,strict 模式抛错时点名 screen-reader 而不是 FFI;在一个受支持的 bun 运行时上,一个屏幕阅读器会话留在 ink 上,而不带这个标志的同一次运行则服务 OpenTUI。
|
||||
|
||||
## 决定 59 —— Space 预览一个会话,并且它是自己的一棵树、而不是装在列表外框里
|
||||
|
||||
决定 52 建起来的那个选择器只能对光标之下的那一行动手。ink 的选择器有第二种模式:Space 载入高亮的会话、并用一个预览把列表换掉,好让用户在承诺恢复或删除之前先读一段转录。这个模式在这里是缺席的,于是 `/resume` 成了在一堆标题里盲选,而选择器自己那条 `Space to preview` 提示什么也没在承诺。
|
||||
|
||||
预览是作为它自己的一棵树返回的、而不是作为换掉列表外框内正文的结果,因为 ink 的预览根本没有外框,而它的转录铺满整个内宽 —— 装进带框外壳里的一个更窄正文,既对不上几何也对不上阅读宽度。两棵树都带一个 key,而这是承重的、不是装饰性的:本渲染器从不清除一次重渲染丢弃的 prop,那正是决定 11 记录的那个失效,所以只把带框列表那个盒子的 border style 省掉再返回,边框仍然画在屏幕上。给两种形态不同的 key 强制卸载、而不是复用。
|
||||
|
||||
转录来自重放被恢复会话的那些事件、且不传 config,于是一个工具组退化成只有它的名字。这不是抄近路 —— ink 的预览把 null config 传给同一个构造器、得到同一种退化,所以两边对「一个预览愿意显示什么」的判断是一致的。那一行元信息在列表条目自带计数时用它、否则在载入的会话里按 user 与 assistant 的唯一 uuid 计数,那是 ink 自己的兜底;而它把各段排成计数在前 —— 与行那条时间在前相反,这也仍然是 ink 的做法。
|
||||
|
||||
预览挂起期间它独占整个键盘:选择器的处理器在读任何别的东西之前就返回,所以敲一个字母起不了搜索、按方向键也动不了一个已经看不见的光标。Esc 与 ctrl+c 回到列表,光标、勾选与查询原样不动;Enter 恢复被预览的那个会话。一次在用户已经离开之后才 resolve 的载入会被丢弃、而不是画在他已经转过去看的东西上。这个模式只给 `/resume` 挂上。`/delete` 跑的是多选模式的选择器,那里 Space 是复选框的切换键,而多选打开时预览一律关闭,所以两者从不争同一个键。
|
||||
|
||||
ink 的两个守卫没有被移植过来,这是有意的。它的选择器在一个调用方同时要求多选与预览时会抛异常;端口对同一个请求的处理是优先多选。两个生产调用方都没有同时要求两者,而一条优先级规则在可观测层面不损失任何东西,抛异常却会把一次接线错误变成一次崩溃。它的预览还以「会话服务存在」为门,而那个模式已经把选择器自己的按键关掉了,所以服务缺失时 ink 的选择器没有任何办法退出一个它画不出来的模式;这里无论如何 Esc 都回到列表。
|
||||
|
||||
ink 自己的预览带一个没有被移植过来的缺陷。它的加载分支是一个普通盒子、而它的就绪分支是一个 static 块,两者的高度差让 ink 擦掉它已经写进 static 区域的内容、并把屏幕滚动好几行:这轮扫查在 ink 腿上抓到的那一帧是空的,而它旁边的原始字节流里带着整段转录。端口让两个状态共用一种形态,于是擦除与滚动都不可能发生。ink 腿的 static 输出仍然被当作意图布局的参照来读,而移植过来的正文在相同的列上换行、在相同的词上断开。
|
||||
|
||||
九条用例在渲染出的行上钉住这个模式:换树与它的 Enter、保住光标与查询的那次 Esc、预览对键盘的独占(ctrl+c 是退出而不是取消)、两条错误文案、按 uuid 去重的计数、多选下 Space 仍归复选框、一个没有 opt-in 的流程什么都不画、以及一次迟到的载入被丢弃。两个场景在真机上覆盖它 —— 一个断言预览的帧、它无框的几何与 Esc 之后逐字节相同的列表,另一个断言 Enter 真的把会话恢复进主转录、而不只是关掉对话框。未在真机上验证的:两条错误分支(要触到它们需要一个损坏的会话文件),以及一段比视口更长的预览内部的滚动。
|
||||
|
||||
## 决定 60 —— 行字形之后那一格是一个列、而不是一个字符
|
||||
|
||||
用户行与助手行都把各自的状态字形和它后面那个空格打在同一个文本节点里,旁边是一个承载正文、并且会生长的兄弟节点。当正文长到要换行时,那个兄弟占满整宽、尾随的空格就被挤掉,于是一条回答读起来是 `◆︎Answer`,一格间隔都没有。帧矩阵此前从没显出过它,因为每一个被播种的正文都短到给那个空格留了地方;决定 59 加进来的预览是第一个把一条刻意会换行的回答放进转录的东西。
|
||||
|
||||
那一格现在是一个只装字形的定宽盒子,宽度取自工具卡片状态列用的同一个常量,所以它不能被一个需要地方的兄弟换走。这与 warning 行当初那个失效是同一个,修法也是同一个;这个形状值得有一处归属,而不是每一行重新发现一次。
|
||||
|
||||
一条测试在两行上钉住它 —— 字形坐在它自己的两列盒子里、且不带任何首尾空白 —— 而任一行改回内联空格都会让它失败。暴露这个缺陷的那一帧现在显示出那一格,并且在 ink 换行的那些列上换行。同族里有一行是刻意没动的:子 agent 摘要行也把它的字形和空格打在内联,但它那三个兄弟全是纯文本、没有一个会生长,所以挤压够不着它。改它会是在猜一个没有任何帧显示过的缺陷。
|
||||
|
||||
## 决定 61 —— harness 按 side query 自己的标记路由它,而不是按主 agent 标记的缺席
|
||||
|
||||
对比 harness 用一个假端点应答每一个请求,而这个端点服务的东西比场景脚本里那段对话多得多。一个轮次之后可能跟着一次会话 recap、一条跟进建议、一个标题、一次 next-speaker 判定、一次记忆召回、一次压缩,以及 —— 当工作区打开了托管记忆时 —— 一个会读写文件的抽取子 agent。只有主轮与它派生的子 agent 才该消费场景的响应表;一个吃掉表项的 side query 会把之后每一条回复静默地挪一位,而产出的那一帧看起来像是渲染缺陷、而不是 harness 缺陷。
|
||||
|
||||
第一个判据是主 agent 的 system prompt 前缀,不带它的请求被当成 side query。这个判据的形状就是错的,因为那个前缀不是一个字符串:每个子 agent 带的是它自己定义给它的那份 prompt,于是一个内置的 general-purpose agent 与用户的一个 `.md` agent 各不相同,而枚举它们不是 harness 能保持为真的事。子 agent 那一帧丢掉了自己的回复。
|
||||
|
||||
替代方案 —— 「流式且带工具声明就是 agent 会话」—— 更糟,因为它看着像一条关于线上的规则、其实不是。托管记忆抽取子 agent 既流式又声明工具;它在每个意义上都是子 agent,只是属于记忆特性而不属于这个轮次。它拿走了场景写给第二条回复的那个表项,于是 recap 场景在等一条已经被花掉的行时超时了。跟进建议从另一侧击穿同一条规则:它逐字复用主 agent 的 prompt,只能靠最后一条 user 消息认出来。
|
||||
|
||||
所以判据反过来跑。每类 side query 由它自己拥有的标记认出 —— recap 自己的指令、建议的模式头、压缩的指示语、抽取子 agent 的 system prompt —— 而不带流式的请求就是一个 JSON 类 side query。其余一切都是 agent 会话、吃响应表。side query 拿到的三种应答是 recap 的文本、一个空字符串(空字符串在这里同时意味着「没有建议」与「无需压缩」),以及那份罐头的空召回 JSON。按正向标记而不是按某个标记的缺席来路由,正是让这个 harness 能扛住一类新 side query 的原因:一个没被认出的会落进 agent 桶、把一条回复挪位,而这会在帧上显形;它不会被静默地用一张它本来就不该读的表应答掉。
|
||||
|
||||
重跑在行为与路由两侧都确认了这一点。子 agent 场景从两分钟后报错变成十二秒落定,recap 场景从在第二条回复上超时变成十七秒落定。随后全量矩阵发出的每一个请求都被归了类:八十个主轮与两个子 agent 轮吃了表,二十六个 JSON 类 side query、两个 recap 与五十六次建议调用没有。没有任何一个表项落到 agent 会话之外的东西上。
|
||||
|
||||
## 决定 62 —— 调色板带上一个版本号,因为 banner 的 memo 看不见一次重绘
|
||||
|
||||
决定 42 让主题活了起来,决定 47 把字标的色标从它身上取了下来,而这两者之间留下了一个启动帧不可能显出来的洞:banner 是被 memo 化的,而一次主题选取所改变的东西,没有任何一样出现在它的依赖列表里。调色板是一个被原地改写的对象,所以它的身份在 `/theme` 之后依然存在;主题管理器写入的那个 settings 对象同样是被原地改写的,所以它的身份也依然存在;而 config 根本没被碰过。于是一个已经挂载的 banner 会继续画它被构造时拿到的那条色标与那个边框色,只有一次改尺寸或者一次重启才能把新主题带进来。矩阵抓到的每一帧都是主题已经应用之后启动的,这就是它熬过这轮清扫的原因。
|
||||
|
||||
调色板现在带一个版本号,由两个改写函数各自递增,而 banner 的 memo 依赖它。一个模块作用域的计数器不是 hooks lint 认可的依赖 —— 它会警告说改写一个外层值不会让任何东西重渲染 —— 所以这一行带着一条抑制注释,理由就写在它旁边。这条抑制是诚实的,而不是绕过规则的手段:重渲染来自主题选取处理函数自己那次状态写入,而计数器要告诉 memo 的是,它闭包里的那些值已经在它底下被重绘过了。
|
||||
|
||||
有一个测试重绘一个已挂载的 banner —— 同一组 config 与 settings 对象在一次主题变更前后被一直握着,而字标的第一格在前后各断言一次。把计数器从依赖列表里摘掉会让它失败,屏上仍是旧色标,而这正是证明该用例有判别力的那次变异。没有在真机上验证过:明暗那个改写函数没有生产调用点(见后续工作),所以被走到的只有具名主题那条路,而且没有抓到过一帧会话中途选取主题的画面。
|
||||
|
||||
## 决定 63 —— ref 镜像延伸到了决定 41 漏掉的两个处理函数
|
||||
|
||||
决定 41 把「读到过期值」这一类记为对光标已经闭合。它并没有。有两个处理函数在它改造的那个共用 select hook 之外,而这两处都是读源码读出来的,不是读帧读出来的。
|
||||
|
||||
rewind 的恢复选项列表把它的光标放在一个 reducer 里,共用 hook 够不着它。它的 Enter 读的是 reducer 的答案,而一次突发还不可能把那个答案送到:一次方向键自动重复后面跟着 Enter,恢复的是方向键移动之前高亮的那个选项。reducer 所施加的那个夹紧现在是一个两边都调用的函数,而处理函数在 dispatch 之前先推进它自己的镜像,因为在一次突发内部 dispatch 不会触发渲染,而一个在渲染期间才重新同步的镜像仍然会握着突发前的那个下标。旁边那次渲染期写入,是让镜像在 reducer 于取消与恢复失败时所做的那些复位之间仍然诚实的东西。
|
||||
|
||||
权限对话框的两个自由文本字段 —— 规则与工作区目录 —— 在后续工作里被列为仍从闭包读取的字符串状态,而它们是那份列表里唯一触发条件是单个手势、而不是两个含义不同的键的成员:一次粘贴路径后面跟着 Enter 会在同一次读取里到达,于是字段提交的是它在粘贴之前握着的那个空缓冲,对话框则原地不动。两者现在都坐在光标所用的那个镜像之后,这个镜像放在它自己的一个模块里、而不是塞在某一个对话框内部,而它们的退格与它们的追加也经由镜像写入,所以一次先编辑再提交的突发端到端是自洽的。仍然开着的是那些为分支充当门禁、而不是喂给一次提交的状态 —— 视图名、步骤、搜索词 —— 它们继续记在后续工作里。
|
||||
|
||||
三个用例钉住 rewind 的处理函数:一次落在第三个选项上的突发、一次夹紧进 `Never mind` 然后为下一次重新同步镜像的突发,以及在 diff 仍在加载时被丢弃的按键。三个钉住权限字段,其中一个在突发内部做编辑。全部六个都在修复前的代码上跑过并在那里失败 —— rewind 的突发报出第一个选项而不是第三个,而权限字段什么都不提交。两个缺陷都没有在真机上复现过:这次突发需要在一次读取里有一次粘贴或者一个按住的方向键,而 harness 打不出这个。
|
||||
|
||||
## 决定 64 —— 镜像延伸到了另外两处读取,而其中一处不是数字
|
||||
|
||||
决定 63 关掉了共用 select hook 之外的那两个处理函数。这一类还有两个成员,同样是读源码读出来的,不是读帧读出来的。
|
||||
|
||||
认证向导的模型步骤把三个目标里哪一个持有焦点 —— 自定义 ID 字段、搜索字段、列表 —— 放在普通状态里,而它的方向键、它的空格与它的粘贴处理函数全都从闭包里读这个状态。于是一个按住的方向键后面跟着空格,会把一个空格打进方向键早已离开的那个字段里,而不是切换它们到达的那一行。composer 的 `@` 分类标签页则从上一次渲染显示的那个标签页起算步进,所以一次读取里的三个方向键只移动了一个标签页、而不是三个。
|
||||
|
||||
光标镜像只搬得动数字,而一个分类是字符串联合,所以镜像被在它旁边推广了:一半管数值光标,一半管任何别的值,两半都把 state 与 ref 一起写。向导的焦点与 composer 的标签页现在坐在这两半上,而共有八个调用点从这一个模块里取镜像、而不是各自长出一个本地形状 —— 共用的 select hook、会话选择器、权限对话框、composer、认证向导,以及 MCP、设置与 arena 三个对话框。
|
||||
|
||||
标签页那一处最有教益,因为它本来就已经用了函数式更新,却仍然读到过期值。在一次突发内部,函数式更新是正确串联的 —— 第三个方向键确实看得见第二个方向键写入的值 —— 而处理函数闭包里握着的那个值从来不动,所以一个把两者混在一起的表达式,仍然是从突发前那个标签页起算的。修法是读镜像、并在它上面显式做算术,两个处理函数现在都是这样;向导的方向键尤其不再从渲染那一份副本推导它们的下一个下标。
|
||||
|
||||
三个用例钉住这一处:一次三键突发把空格落在同一次读取里方向键到达的那一行上、一次两键突发切换一个方向键移动到的那一行,以及一次读取里的三个方向键把标签页带到 Extensions。全部三个都在修复前的代码上失败 —— 两个向导用例把哪一个单选字形是实心的弄反了,而标签页用例既找不到扩展行也找不到文件行,因为每个方向键都是从 `all` 起算步进的。两个缺陷都没有在真机上复现过,理由是决定 63 给的那一个。向导的 review 行仍然读闭包,继续记在后续工作里。
|
||||
|
||||
## 决定 65 —— 审批模式对话框记住方向键到达的那一行
|
||||
|
||||
ink 自己保有一份高亮模式的状态,并用它来给列表下标做初值,所以从作用域那一步回来时,恢复的是方向键最后一次高亮的那一行,而不是 config 恰好握着的那个模式。这里则是每当作用域那一步关闭,列表都从 config 取初值,于是光标被放回当前模式上,而方向键走过的那段路被悄悄丢掉了 —— 一个往下移了两行、去核对作用域、再按 Enter 的用户,写入的是他出发时那个模式。
|
||||
|
||||
对话框现在按 ink 的做法保住高亮模式,从列表的高亮回调里取它,并用它给列表做初值。作用域保留它自己那个键:作用域是这一趟所改变的东西,而高亮是这一趟之后存活下来的东西。
|
||||
|
||||
一个用例把模式列表往下走一行,去作用域那一步切到 Workspace 再回来,然后断言光标停在方向键到达的那一行上。改成从 config 取初值会让它失败。这个用例在那次变异之下起初看起来是绿的,而原因值得留着:在作用域那一步不移动作用域光标就按 Enter,写入的是它本来就有的那个作用域,于是列表据以重新同步的那个键根本没变,列表从没被重新落位,而光标在两个版本下都停在方向键留下的地方。在 Enter 之前移动作用域光标,才是让这个用例具备判别力的东西。一个以某个值为键的重新同步,必须看见那个值真的变了,而一个要用到它的用例也必须让它变。
|
||||
|
||||
## 覆盖边界
|
||||
|
||||
验证了什么,以及验证到哪一层:
|
||||
|
||||
- **几何、行内容、行顺序、行数与字形身份**,在重建屏幕上,覆盖二十七个场景 ——
|
||||
二十二个 100×40,三条转录臂 100×30,窄终端启动 60×24,标签行 58×40 —— 总计
|
||||
七十八个检查点,其中二十二个逐字节一致。两腿出自同一个 bundle 与同一组启动参数。
|
||||
- **颜色除一类行之外未验证。** 重建结果是文本。已知有若干行只在所用主题 token
|
||||
上不同。七十八个检查点里除三个外都有带样式的抓取兜着,而读过的只有一类行 ——
|
||||
决定 32 的光标格 —— 其余没有读。
|
||||
- **几何、行内容、行顺序、行数与字形身份**,在重建屏幕上,覆盖四十三个场景 ——
|
||||
三十八个 100×40,三条转录臂 100×30,窄终端启动 60×24,标签行 58×40 —— 总计
|
||||
一百三十一个检查点,其中按抓取到的非空行有三十三条一致;把上下文文件通知与它
|
||||
折行成的路径碎片从两腿都丢掉之后,有七十三条一致。两腿出自同一个 bundle 与同一组启动参数。
|
||||
- **颜色读过三类行、两种终端模式,其余未读。** 重建结果是文本;颜色来自它旁边那份
|
||||
带样式的抓取,而 harness 除退出之后那一个检查点外,每一个都写了这份抓取。读过的
|
||||
三类行是 —— 决定 32 的光标格,以及 banner 字标与 banner 信息面板外框。后两类最初
|
||||
是在不同模式的不同帧上读的:默认主题的启动帧在真彩下读,那时只有字标分歧;具名主题
|
||||
的启动帧在 256 色下读,外框也分歧 —— 外框那处是两个库的降采样规则,已记在后续工作
|
||||
里,字标那处是决定 47。随后两类都在真彩下重读过,一次在默认主题的启动帧上,一次在
|
||||
Dracula 的启动帧上。两张帧各自的六行字标里,ink 用色标染的每一格 —— 共 221 格 ——
|
||||
与本移植逐格一致,外框也在两腿发出同一个源十六进制值。每张帧上仍然不同的那 25 格全
|
||||
是空白格:ink 对它们不发颜色,本移植把它们留在渲染器自己的默认值上,而那是不透明白。
|
||||
那是后续工作里那条「不着色文本」的分歧,不是色标的差异,且早于决定 47。其余每一行
|
||||
的颜色都没有读,而在决定 43 之前,已知有若干行只在所用主题 token 上不同。
|
||||
- **滚动条没有从转录里拿走一列。** 在一百列下用两条分别为 95 与 96 字符的回答行
|
||||
测量:本渲染器折满 99 列才断行,ink 折满 98 列,所以这一列的差与清单上那条既存的
|
||||
换行分歧同向,而不是滚动区域拿走了一列 —— 且整份语料的行长分布在这次改动前后完全
|
||||
@@ -1352,10 +1801,14 @@ bracketed paste 就能把它停在一个值里;用户正在注视的那一格
|
||||
- **审批释放只有单元测试覆盖。** 没有任何场景会先停住一个确认框、再轮换模式。这条
|
||||
接线由一个带反证对照的测试钉住 —— 那个必须什么都不释放的中间模式 —— 而它消费的
|
||||
选取规则本来就已被单独覆盖。
|
||||
- **每一个突发用例都只有单元证据。** 验收 harness 故意每次写只打一个字符,所以一次
|
||||
按键落在自己那一次 stdin 读取里,而它从不向对话框输入粘贴。因此矩阵的两条腿都产生
|
||||
不了决定 33、34、37 所针对的多键读取;这些结论只靠它们新增用例的那两个对话框套件,
|
||||
以及那几个决定各自在覆盖段落里点名的变异,背后没有任何屏幕证据。
|
||||
- **多数突发用例只有单元证据;光标这一类不是。** 验收 harness 故意每次 pty 写只打
|
||||
一个字符,所以一次按键通常落在自己那一次 stdin 读取里,而它从不向对话框输入粘贴。
|
||||
现在有两个场景为陈旧光标这一类故意打破这条规则:各自在一次 pty 写里写入三个方向
|
||||
键、或两个方向键加一个 Enter,于是这些键落在同一次 stdin 读取里,那个读渲染闭包
|
||||
光标的处理函数就会从错误的行上作答。两条腿都逐帧钉住了,在两个不同的对话框上,
|
||||
其中第二个同时还是一个环绕用例。仍然只由单元测试套件及其变异兜着的是其余全部:
|
||||
决定 37 与 39 描述的粘贴交错、重试计数器的步骤守卫、决定 40 的答案锁与净化片段
|
||||
—— 这些都不是单通道 pty 写能造出来的。
|
||||
- **ink 自己的认证向导套件在本地失败,而本轮改动没有被证明是原因。** 那个文件把十九条
|
||||
测试藏在一个由它自己注释解释的检查之后 —— 模拟 TUI 输入在慢速 runner 上不可靠 ——
|
||||
并在 CI 与 win32 上跳过它们,所以 CI 从不运行它们。在 CI 之外,这一家里有十三条在这
|
||||
@@ -1375,11 +1828,11 @@ bracketed paste 就能把它停在一个值里;用户正在注视的那一格
|
||||
这里的工具卡片无论它如何都保留自己的行数上限。
|
||||
- **进行中的思考正文在这里是流式显示的。** ink 会把它藏起来,直到该思考被展开。记录
|
||||
而不修改:既然没有场景产出一个进行中的思考,改动它在任一方向上都无法验证。
|
||||
- **`output.showTimestamps` 在这里仍然没有读取点。** 该设置打开时,ink 会在 assistant
|
||||
行上方打印一行暗色的时钟。接上它需要在共享的条目类型上加一个时间戳、在条目被创建
|
||||
处打上,并且还需要恢复路径上的第二个来源 —— 那条路径是从录制重建 transcript,而不
|
||||
是从实时条目。只做实时的一半本身就会成为一个分歧 —— 一个恢复出来的会话会完全不显
|
||||
示时间戳 —— 所以它是整体推迟,而不是先上一个半成品。
|
||||
- **决定 48 接上的那两个设置,只在一个场景、一个尺寸上有帧验证。** 两半都在那个专门场景
|
||||
的帧上与 ink 逐帧读过:assistant 行上方的时钟行,以及结算卡片被折叠成只剩标题并带着展开
|
||||
提示。没有用帧查过的:其他任何视口尺寸;恢复出来的会话 —— 那里的时间戳来自录制,而不是
|
||||
折叠自己的时钟;以及折叠卡片的点击可供性,没有场景点过卡片,所以它靠的是那个钉住提示文案
|
||||
与其所指处理器的单元测试。
|
||||
- **哪些工具真的会在执行中发出结构化载荷,没有被追查。** 这次收敛让每一种载荷在每一
|
||||
条路径上都可用,实时分片也包含在内,所以一个在运行时上报 ANSI 网格的 shell 结果现
|
||||
在会带颜色渲染;至于是否有工具会在完成之前就这么做,那是关于工具的问题,不是关于
|
||||
@@ -1401,6 +1854,26 @@ bracketed paste 就能把它停在一个值里;用户正在注视的那一格
|
||||
分配:每一张卡片拿到的都是同一个按条目算出的上限,它由视口高度导出,所以参数行在这里
|
||||
没有可扣的预算、只会额外增加行数。两者只在 ink 本会压缩一条长结果的地方不同,而要补上
|
||||
这段差距,就得引入这套架构从来没有过的分配。
|
||||
- **决定 51 到 58 靠的是它们各自的单元测试套件与变异,而不是在这八条之上重跑一遍
|
||||
帧矩阵。** 决定 51、53、56 背后那几处外观分歧 —— 编辑器对话框的行与提示、认证
|
||||
外框的方角与它缺失的无推荐分支、footer 的模型段 —— 是在这轮排查里从重建帧上读出
|
||||
来的,而决定 57、58 背后那两道门 —— `ui.mouseTracking` 与屏幕阅读器模式 —— 各自
|
||||
在真机终端上两个方向都确认过,正如它们的条目所记。其余各条是就 ink 做源码核验。
|
||||
一个屏幕阅读器会话根本到不了 OpenTUI 这条腿,因为决定 58 把它留在 ink 上,所以那条
|
||||
路径没有矩阵可比的帧。
|
||||
- **机器腿在这个 head 上重跑过,并复现了它之前的那一轮。** 八十六次运行全部完成,没有
|
||||
一次报错,而九次 idle 超时落在与那一轮相同的三条 ink 腿上、次数也相同。逐帧对比下来,
|
||||
258 帧里有 243 帧与它字节相同,另有三帧只差一个 spinner 字形或一个耗时数字。剩下十二
|
||||
帧的内容差异在两臂上同时出现、且形状相同 —— 两个检查点上是 spinner 的随机短语,一个是
|
||||
会话标识,三个是墙上时钟 —— 所以没有任何一帧能把一处变化归到本轮。对比工具自己的计数
|
||||
也逐项复现:一百三十一个检查点里三十三个一致。剔除上下文文件通知之后的那个拆分本轮没有
|
||||
重算;它是这些帧所属的那一轮给出的数字。
|
||||
- **决定 62 到 65 背后没有帧。** 调色板版本号、那两处镜像读取,以及审批模式对话框记住
|
||||
的那一行,全都是就 ink 读源码读出来的,各由一个单元测试钉住,并用变异证明它有判别力。
|
||||
这四条没有一条能从抓到的帧上走到:那次突发需要在一次 stdin 读取里有一次粘贴或者一个按
|
||||
住的方向键,而 harness 打不出这个;那次主题重绘需要一次会话中途的选取。footer 的提示
|
||||
行是从另一面看同一形状 —— 它在一行之内逐段截断,有一个用例在四十四列上钉住了这件
|
||||
事,但矩阵跑过的最窄视口是五十八列,所以没有一帧显示过一行不得不丢掉某一段的提示。
|
||||
|
||||
## 后续工作
|
||||
|
||||
@@ -1420,8 +1893,9 @@ bracketed paste 就能把它停在一个值里;用户正在注视的那一格
|
||||
型的结果行 —— 仍然是那个共用的 select 控件 —— 两样都还响应。给 chip、自由文本行以及
|
||||
submit 与 cancel 行上的点击各找一个含义,是独立的改动,正如决定 21 所说;在那之前,这
|
||||
个对话框只靠按键作答。
|
||||
- 这里的 `@` 补全只问文件索引。ink 还会补全会话、MCP 资源与扩展,并画一个分类
|
||||
栏用于切换。那是功能缺口而不是一致性缺陷,应放进独立的改动。
|
||||
- 决定 49 新加的四个 `@` 补全分类里,有两个只有单元测试覆盖。那个专门场景的工作目录里
|
||||
有文件和过往的会话,所以它的帧画出了这两页标签与它们的行;它没有声明 MCP 服务器、也没
|
||||
有安装扩展,所以那两页标签从未被画出来,标签列也从未在屏幕上量过。
|
||||
- 缺少搜索二进制时打印的 shell crawler 诊断现已有解释。渲染器库把全局 console
|
||||
替换成一个捕获流,并把 console 输出收进它自己的内置控制台,所以那些警告根本到
|
||||
不了终端;ink 没有这类拦截,任由它们落进 scrollback。触发条件是环境性的 ——
|
||||
@@ -1435,11 +1909,20 @@ bracketed paste 就能把它停在一个值里;用户正在注视的那一格
|
||||
钉住这两个数字的是单元测试里的算术 —— 而它断言的是常量自己的加总,不是屏幕。
|
||||
因此确认框的几何将来发生变化时,这两个数字不会自己报错;重新推导它们应当与那
|
||||
次改动一起做。
|
||||
- 会话选择器的对话框不会收缩到它的内容。它的盒子被定尺寸到一个固定高度 —— 视口
|
||||
减一行 —— 而列表的滚动窗口由它派生,所以一个较短的列表仍然填满整个高度,把
|
||||
composer 顶下去、并在最后一个会话之下多出空白行。决定 52 复用了 ink 的行形状与
|
||||
窗口算术,但故意把这个高度留成固定的:它是决定 16 描述的那个间距家族里「按内容
|
||||
定尺寸」的那一半,而要收缩它就意味着由显示出的行、而不是由视口去派生这个盒子,
|
||||
再重新推导窗口据以算出的那个保留行常量与行高。
|
||||
- loading 指示器没有子代理 token 汇总,也没有 tokens-per-second 段,两者 ink
|
||||
都有。它的耗时计数器在一个调用被停到 shell 里之后也会重新起算,原因正是决定 28 记下
|
||||
的那个:那一行在两个互斥分支里各挂载一次,于是累积过时间的那个实例并不是接着走的那
|
||||
个。要把这段时间带过那次交换,就得在两处挂载之上持有这个累积器。
|
||||
- 主题模式 helper 没有生产调用方,所以本渲染器永远画它的暗色调色板。
|
||||
- 主题明暗模式 helper 没有生产调用方。决定 42 已经把 ink 的主题接通了,所以调色板确实
|
||||
跟着当前主题走;这两个 helper 是通往同一终点的第二条路,而没有任何代码在两条路之间做
|
||||
选择,唯一能走到它们的是一个重置调色板的测试 fixture。要么它们得到一个调用方,要么它们
|
||||
被删掉。
|
||||
- 仍有两个对话框列表组件,其实一个就够;合并它们会同时牵动编号、颜色与滚动箭
|
||||
头。
|
||||
- 一次针对 ink 组件与渲染源码的逐行对照,翻出了远超本次改动范围的缺口。以及在这里打开
|
||||
@@ -1473,7 +1956,8 @@ bracketed paste 就能把它停在一个值里;用户正在注视的那一格
|
||||
其中十三处在分发器自己那对 invocation/result 之外;这十三处里有三处在这里什么都不
|
||||
必补:theme 那处只带 `/theme` 的 `NO_COLOR` 消息,而本渲染器已经把它走分发器的记录
|
||||
包装(选主题本身两边都不加行)、arena 命令的记录器是两边跑同一份的共用代码、
|
||||
away-summary 那处住在只有 ink 自己的容器会挂载的 hook 里。
|
||||
away-summary 的记录器现在住在决定 55 里本渲染器挂载的那个 hook 内,所以它触发的
|
||||
recap 与 ink 的记录方式一致、在这里不需要自己的一处记录点。
|
||||
- 一次辅助模型的选取是另一种类型的行。ink 把 fast、voice、vision 的选取报成 success 条
|
||||
目 —— 它有自己的字形与颜色 —— 而这里所有模型结果,主要的或辅助的,都走同一条 info
|
||||
行,于是它在屏幕上显得更平,自决定 31 记录了被显示的那一行之后,在会话日志里也一
|
||||
@@ -1485,18 +1969,27 @@ bracketed paste 就能把它停在一个值里;用户正在注视的那一格
|
||||
画出来,而复核标签页把它报告为未回答。这是在真机上观察到的,不是推出来的。
|
||||
决定 21 不跟随它,而在以 ink 现状行为度量自己的清扫里去修 ink 超出了范围;
|
||||
记在这里,免得两个渲染器间的这处分歧被再次当成移植缺口上报。
|
||||
- 读到过期光标这一类缺陷,只在决定 33 动过的那两个处理函数里被关掉。其余读源码即可确认:
|
||||
共享选择 hook、竞技场对话框的模型列表、输入框的补全行,以及认证向导的那四个列表步骤
|
||||
加它的主、子菜单,全都是按上一次渲染画出的那一行作答。镜像的形状已经由提问流程和行
|
||||
编辑器立好,所以这是一次覆盖这些控件本身的改动,而不是逐个对话框各修一遍 —— 并且每
|
||||
一处都要有自己的突发用例:一个让提问对话框失败的变异,对一个它不碰的列表什么都没说
|
||||
明。
|
||||
- 读到过期值这一类缺陷,对光标已经关掉,对光标之外的一切仍然开着。决定 41 把数值光标
|
||||
放到了 ref 镜像之后 —— 共用的 select hook 与它暴露出来的那个 ref、composer 的补全
|
||||
行,以及每个对话框自己的标签页、已勾选集合与向导菜单 —— 决定 63 又补上了那个论断漏掉
|
||||
的两个处理函数,即 rewind 的恢复选项光标与权限对话框的两个自由文本字段,决定 64 再补上
|
||||
认证向导模型步骤的焦点与 composer 的 `@` 分类标签页,所以不再有处理
|
||||
函数按上一次渲染画出的那一行、或者那一个缓冲作答 —— 唯一例外是向导的 review 行,它们仍
|
||||
然读闭包,就记在这条旁边。仍然这样的,是同一个根因落在那些为
|
||||
分支充当门禁、而不是喂给一次提交的状态上:对话框的视图名、它的步骤、它的聚焦区、它的
|
||||
搜索词,以及它的编辑器偏好作用域,在那些以它们为门禁的分支上全都是从渲染闭包里读出来
|
||||
的。这是跨六个对话框读源码读出来的,没有在真机上复现。触发条件比光标那一类更窄:它需
|
||||
要在一次 stdin 读取里有两个含义不同的键 —— 一个 Tab 或 Escape 加一个 Enter —— 而 ink
|
||||
同构的那份代码同样读它自己的渲染闭包,所以单键行为两边一致。要关掉它,得围绕一个镜像
|
||||
重写每个对话框的分支结构,而不是在一个值旁边再加一个镜像,这也是它被记录而不是被做掉的
|
||||
原因;如果要做,每个对话框都要有自己的连击用例,因为让一个对话框的门禁失败的变异,对另
|
||||
一个什么也没说明。
|
||||
- 决定 37 的闩停住的是字段,不是步骤。向导的那几个列表步骤和最后的 review 行仍然是
|
||||
一个按键跑一个分支。review 行在一次两个 Enter 的突发里会把计划应用两次 —— 两次
|
||||
写入调用,本轮一条临时测试直接读到的 —— 而协议行与端点选项行是源码读出的同一形状:
|
||||
它们各自的 select 都会前进一步,于是同一次读取里的第二个 Enter 就跳掉中间那一步。
|
||||
ink 的步骤也是这个形状,所以这不是一条要走在 ink 前面的分歧,它属于上面那轮控件级
|
||||
的改动,同一个「一次读取一个动作」的闩一并覆盖它。
|
||||
ink 的步骤也是这个形状,所以这不是一条要走在 ink 前面的分歧,它属于上面那轮对对话框分
|
||||
支结构的重写,同一个「一次读取一个动作」的闩一并覆盖它。
|
||||
- 闩停住的是按键,不是粘贴。每个被它结算的字段同时订阅了渲染器的粘贴事件,而那份订阅
|
||||
从不查这个闩:一次读取里先有让向导前进的 Enter、随后再到一个括起式粘贴,粘贴的文字
|
||||
就会写进这串按键已经离开的那一步。反过来的顺序 —— 先粘贴、再有提交它的 Enter ——
|
||||
@@ -1568,3 +2061,99 @@ bracketed paste 就能把它停在一个值里;用户正在注视的那一格
|
||||
的,既没有宽度也没有行界,所以一个较长的问题或较长的描述会把对话框撑过终端高度、把还要
|
||||
去选的选项推出屏幕,其中没有任何用户输入 —— 那正是决定 39 那道界要防止的结果,而它可以
|
||||
从一个那道界不覆盖的载荷到达。
|
||||
- 本渲染器画出的、自己没有颜色的文字仍然带着一个显式前景色,而 ink 的那份不带,并且它的
|
||||
覆盖面比空白格更宽。这是从同一帧启动画面在两条腿上带样式的抓包里读出来的:banner logo
|
||||
两侧的空白格,ink 发出时前景色不设,交由终端自己的默认色去画,本渲染器则发出一个显式的
|
||||
白色。这一部分以现状是看不出来的 —— 那些格是空格,两侧的背景也都不设 —— 但它不是同一条
|
||||
指令,而一旦某个选中或反色片段盖住其中一格,在任何默认前景色不是白色的终端上就会显出
|
||||
来。看得见的部分是一整类行:同一帧上那条扩展刷新提示,ink 发出时整行没有一格带前景色,
|
||||
本渲染器却用自己的暗色 token 画它。ink 之所以不设,是因为那一帧启动所用的主题,它的
|
||||
primary 取自暗色基础 token,而那里的前景是空字符串,一个空颜色根本不发指令,于是在默认
|
||||
前景比暗色 token 更亮的终端上,这一行在这里读起来更暗。要闭合它,得有一种向渲染器表达
|
||||
「没有颜色」的办法,而元素的前景 prop 并不提供这种办法。
|
||||
- palette 里有一个键身兼两职,这也是上面那个前景色不能被简单地不设的原因。映射里的 `text`
|
||||
既是本端口给自己的纯文本上色的那个颜色,又是 markdown 语法 `default` token 的锚点,而
|
||||
这里启动的主题把 ink 的 primary 留成空,于是映射退回到该主题自己的 hljs 前景色,把两者一
|
||||
起保住。要让 ink 那个「没有颜色」在纯文本上得到尊重,得先把这两个角色拆开;在那之前,本
|
||||
渲染器会给未着样的 markdown 指名一个前景色,而 ink 把它交给终端。
|
||||
- 无颜色主题不被尊重。ink 达到这个主题的办法是把集合里每个颜色都设成空字符串;本渲染器
|
||||
的映射保留了这些空字符串,而它随后应用主题时又把它们全部过滤掉,于是调色板留住了初始
|
||||
化时那份内置暗色表面,在 ink 什么颜色都不画的地方画出一整套配色。过滤本身是对的 —— 空
|
||||
字符串意为「未设」,赋上去会退化成渲染器自己猜的一个颜色 —— 所以缺的是一个分支:认出这
|
||||
个主题作为整体是空的,并把那个表面一并丢掉。
|
||||
- 一个十六进制颜色向下转换到 256 色调色板的索引,两侧用的规则不同。这是量出来的,不是推
|
||||
出来的:具名主题下、256 色抓取里的那条 banner 信息面板外框,在 ink 下落在调色板索引
|
||||
103,在这里落在 61。默认主题启动帧上、真彩抓取里的同一条外框,在两腿携带完全一致的源
|
||||
十六进制值 —— 所以两侧是从同一个颜色出发,在转换里分道的。ink 那个索引正是「每个通道四
|
||||
舍五入到六级」的公式对这个十六进制值给出的结果;本渲染器的转换发生在渲染器库的原生层
|
||||
里,从 JavaScript 产物中读不出来,所以它的规则没有被确立 —— 只有它的输出被确立。这个移
|
||||
植里没有任何地方选择索引,所以这里没有可修的点;改动只能来自那个库。记下来,因为它是本
|
||||
轮每一处修复之后仍然存活的那一处量到的色差,也因为任何在非真彩色终端上比对两条腿的人
|
||||
都会重新发现它。
|
||||
- 本渲染器带着的那个自动主题模块没有调用方。只有它自己的测试引入它;主题是通过 ink 的主
|
||||
题管理器到达这个移植的,而明暗探测是在那条边界的另一侧解析的。要么它得到一个调用方,要
|
||||
么它被删掉,而上面记下的那两个明暗模式 helper 面对的是同一个问题。
|
||||
- 两份 markdown 实现在不同的地方断行,并把列表项的 bullet 放在不同的列上。两者都是在同
|
||||
一份正文上分别经由各自那份渲染量出来的。宽度不是原因:思考正文所在盒子里一段不断开的连
|
||||
续字符在这里断在第九十四列,而那正是 ink 自己那份正文拿到的宽度。一份由四字符词组成的
|
||||
正文,在这里填满那九十四列中的八十九列,在 ink 下填满九十四列;一份由单字符词组成的正
|
||||
文,在这里填满九十三列,而九十四列是放得下的 —— 两者都与「这一侧把一行最后一个词之后的
|
||||
那个空格也算进这一行」一致,不过这条规则是从这些测量里推出来的,不是从渲染器里读出来
|
||||
的。ink 自己那四行并不遵循这里确立的任何一条规则:它们在第四列放十九个词、在第五列放
|
||||
十八个、在第四列放十九个、在第五列放四个。另外,ink 在同一份正文的第五列画出列表项的
|
||||
bullet,这一侧画在第四列。两处分歧都属于那两份 markdown 渲染器,而不属于这个移植,这里
|
||||
都没有可修的点。记下来,因为决定 45 把思考正文挪到了 markdown 上,所以它们在那里显出
|
||||
来,也因为这个移植以 markdown 渲染的任何其他正文同样带着它们。
|
||||
- 一个不可断的 token 在两侧折在不同的列上,而这是矩阵抓到的几乎每一帧都会碰上的那一处分
|
||||
歧。上下文文件通知里那条路径没有空格,于是整条路径就是一个 token。ink 把这个 token 挪
|
||||
到单独一行、再在九十八列上硬断;本端口把它起头的那一行填满、在剩下的列数上断 —— 在量过
|
||||
的那一帧上是九十五列。两者都说得通,而两者都不是一个可以照抄过去的宽度常量,因为两侧分
|
||||
歧的是一条「对当前行放不下的 token,是挪走还是就地断开」的规则。它与上面那条 markdown
|
||||
折行一样是记录而不修:规则在布局里,不在本端口选择的某个数字里。
|
||||
- 渲染器不重绘一个「新内容是空格」的格子。在十帧上量到,而且它不会自愈:会话选择器的九个
|
||||
检查点与预览场景的那一个,扩展刷新通知里都有盒线字形站在它的六个空格上 —— 那是选择器把
|
||||
banner 行顶出屏幕之前坐在那些格子上的字形。同一条通知在它落到的行本来是空的那些帧上渲
|
||||
染干净,同一帧上选择器自己的框也从头到尾干净,所以这不是「没有清屏」;这是一次差分写入
|
||||
,它跳过空格格,前提是底下那一格已经是空的。这个前提只在渲染器自己的缓冲与终端一致时成
|
||||
立,而一次把行往上挪的滚动会让那个被顶掉的行上的一致失效。写入发生在渲染器库的原生层,
|
||||
所以这里没有可修的位点;记录它,是因为它在真实终端上是看得见的乱码,也是因为一个绕法必
|
||||
须在视口每次滚动时强制一次整屏重绘。
|
||||
- 一个模态对话框在这里按内容撑高,在 ink 下按视口剩下的部分撑高。ink 的审批模式框从命令
|
||||
的下一行一直画到屏幕底 —— 三十五行,其中二十二行是框内的空行,按键提示靠近底部 —— 而本
|
||||
端口画十三行、把空间留在框的上方。两个框里的每一行都逐字相同,包括那个选中标记,所以这
|
||||
仅仅是框的高度。它与「会话选择器的框不收缩」同族,记录而不修的理由也相同:把一个对话框
|
||||
撑到视口高度是一个一次波及每个对话框的布局决定。
|
||||
- 有两行在 ink 下截断、在这里折行或溢出。补全列表的参数提示在 ink 下被裁到它自己那一列里
|
||||
的一个省略号,在这里折到第二行、并对齐到那一列的起点。窄终端上的标签行在 ink 下把每个
|
||||
chip 的标签裁到省略号,在这里原样打印整个标签,代价是它们之间的间距。两行都没有对着
|
||||
ink 的截断位点比过,所以那个预算是一个共用常量还是两个,没有确定。
|
||||
- 受门禁的服务器对话框的正文段落把它的续行缩进了一列。两侧在段落的第一行上、以及那一行承
|
||||
载的八十八列文本上都一致;只有断行之后的那一行在这里多了一格前导空格。它与上面记录的
|
||||
markdown 分歧形状相同,也可能同源。
|
||||
- ink 丢掉了受门禁的服务器对话框那个框的右手角落。那个对话框比 ink 其余的对话框多缩进一
|
||||
列,它的顶边与底边画到九十四格横线,而收尾的角落在帧上不存在 —— 框没有闭合。同样的边框
|
||||
在审批模式对话框与会话选择器对话框上、在少一列的缩进下,两侧都闭合。本端口画的是一个窄
|
||||
一列的闭合框,所以在这一行上对的是本端口,这处分歧被记录而不是被对齐。
|
||||
- ink 有一帧上会话选择器几乎完全不在场。按下预览键会卸载列表,而在预览体画出来之前抓到的
|
||||
那一帧只有三行 —— banner 的最后一行、通知与命令 —— 没有列表、没有预览、也没有 composer
|
||||
。下一个检查点又是列表了,所以这是一帧过渡而不是一个缺失的特性,而本端口在同一个检查点
|
||||
上画出了完整的预览体。记录它,是为了让这一帧空画面不要被再次当成移植缺口报上来。
|
||||
- 统计对话框自带一份 ref 镜像的副本,而不是调用那个共用的 hook,并且每次渲染都重建它的写入
|
||||
函数。两者都停留在外观层面:副本做的是同一个双写,而写入函数的身份没有任何东西拿去做
|
||||
memo 的依赖。它属于那两个对话框列表控件合并的一项,而不是单独落一次。
|
||||
- 对话框外框声明了一个它从不读取的关闭处理函数。每一个需要关闭的调用方都是从它自己的按键
|
||||
处理函数里关的,所以那个 prop 是类型里的一个死可供性,不在屏幕上。外框的测试钉住了圆角
|
||||
轮廓以及标题的颜色与字重,却没有钉住认证对话框所要的那个方角轮廓,所以对那一种与众不同
|
||||
的边框样式做改动会直接通过。
|
||||
- 三个分支的形状没有任何一次运行会到达。composer 的失焦边框颜色只被一个测试走到,因为唯一
|
||||
的生产挂载点把那个标志留在默认值上。footer 的审批模式颜色给一个枚举没有声明的模式涂上暗
|
||||
色,而 ink 让那个标签不着色,且枚举是封闭的,所以两侧都跑不到。共用的列表步进把它的行类型
|
||||
写成 unknown,并通过一次 cast 读取 disabled 标志,因为各个调用方的行形状并不相同,而一个
|
||||
声明出来的标志会在每一个从不设置它的调用方上触发弱类型检测。
|
||||
- banner 的渐变数的是码点,而 ink 渲染所经过的那个库数的是 UTF-16 单元,所以一行含有代理对
|
||||
的字标会以差一步的方式上渐变。自带的字标一个代理对也没有 —— 它是框线字符,全部在基本平面
|
||||
之内 —— 但 banner 也接受一份自定义字标,所以用户字标里的一个 emoji 会在两侧染成不同的
|
||||
颜色。
|
||||
- 助手行之上的那个时钟标签存在两份。端口调用决定 48 新增的共用格式化函数;ink 的显示组件仍然
|
||||
内联格式化同一个 24 小时带方括号的时间,用的是一次完全相同的 locale 调用。把 ink 指向共用的
|
||||
那一个,会改到帧证据正是对着它抓取的那个文件,所以第二份副本留着,两者只靠人工保持相等。
|
||||
@@ -24,6 +24,8 @@ type StreamToolCallDelta = {
|
||||
type StreamChunk = {
|
||||
choices: Array<{
|
||||
delta: {
|
||||
content?: string | null;
|
||||
reasoning_content?: string | null;
|
||||
tool_calls?: StreamToolCallDelta[];
|
||||
};
|
||||
}>;
|
||||
@@ -126,6 +128,72 @@ describe('fake OpenAI server', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('streams reasoning as reasoning_content deltas ahead of the content', async () => {
|
||||
server = await startFakeOpenAIServer(({ requestIndex }) =>
|
||||
requestIndex === 0
|
||||
? { reasoningChunks: ['Thinking ', 'about it'], content: 'the answer' }
|
||||
: { content: 'no reasoning here' },
|
||||
);
|
||||
|
||||
const streamed = await fetch(`${server.baseUrl}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: 'fake-model',
|
||||
stream: true,
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
}),
|
||||
});
|
||||
const frames = (await streamed.text())
|
||||
.split('\n\n')
|
||||
.filter((frame) => frame.startsWith('data: {'))
|
||||
.map((frame) => JSON.parse(frame.replace(/^data: /, '')) as StreamChunk);
|
||||
const deltas = frames.flatMap((frame) => frame.choices ?? []);
|
||||
const reasoning = deltas
|
||||
.map((choice) => choice.delta.reasoning_content)
|
||||
.filter((text): text is string => typeof text === 'string');
|
||||
expect(reasoning).toEqual(['Thinking ', 'about it']);
|
||||
// The thought has to precede the text it explains: the converter builds
|
||||
// the thought part from the delta that carries it.
|
||||
const reasoningAt = deltas.findIndex(
|
||||
(choice) => choice.delta.reasoning_content !== undefined,
|
||||
);
|
||||
const contentAt = deltas.findIndex(
|
||||
(choice) => typeof choice.delta.content === 'string',
|
||||
);
|
||||
expect(reasoningAt).toBeLessThan(contentAt);
|
||||
|
||||
const plain = await fetch(`${server.baseUrl}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: 'fake-model',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
}),
|
||||
});
|
||||
expect(await plain.text()).not.toContain('reasoning_content');
|
||||
});
|
||||
|
||||
it('serves joined reasoning on the non-streaming path', async () => {
|
||||
server = await startFakeOpenAIServer(() => ({
|
||||
reasoningChunks: ['a', 'b'],
|
||||
content: 'answer',
|
||||
}));
|
||||
|
||||
const response = await fetch(`${server.baseUrl}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: 'fake-model',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
}),
|
||||
});
|
||||
const payload = (await response.json()) as {
|
||||
choices: Array<{ message: { reasoning_content?: string } }>;
|
||||
};
|
||||
expect(payload.choices[0]?.message.reasoning_content).toBe('ab');
|
||||
});
|
||||
|
||||
it('holds a streamed response until the caller releases it', async () => {
|
||||
let release!: () => void;
|
||||
server = await startFakeOpenAIServer(() => ({
|
||||
|
||||
@@ -29,6 +29,14 @@ export type FakeOpenAIResponse = {
|
||||
model?: string;
|
||||
content?: string;
|
||||
contentChunks?: string[];
|
||||
/**
|
||||
* Reasoning text, emitted on the wire as `reasoning_content` — the field the
|
||||
* OpenAI-compatible converter reads into a thought part. Streamed as one
|
||||
* delta per entry of `reasoningChunks`, or as a single delta otherwise, and
|
||||
* always before the content deltas of the same choice.
|
||||
*/
|
||||
reasoning?: string;
|
||||
reasoningChunks?: string[];
|
||||
errorContent?: string;
|
||||
disconnectAfterContentChunks?: number;
|
||||
/**
|
||||
@@ -56,6 +64,8 @@ export type FakeOpenAIChoice = {
|
||||
index: number;
|
||||
content?: string;
|
||||
contentChunks?: string[];
|
||||
reasoning?: string;
|
||||
reasoningChunks?: string[];
|
||||
/**
|
||||
* Streaming-only mid-stream provider error: emits one chunk carrying this
|
||||
* string as `delta.content` together with `finish_reason: 'error_finish'`,
|
||||
@@ -270,6 +280,9 @@ function writeNonStreamed(
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: choice.content ?? choice.contentChunks?.join('') ?? null,
|
||||
...(choiceReasoning(choice)
|
||||
? { reasoning_content: choiceReasoning(choice) }
|
||||
: {}),
|
||||
...(choice.toolCalls ? { tool_calls: choice.toolCalls } : {}),
|
||||
},
|
||||
finish_reason: finishReason(choice),
|
||||
@@ -328,6 +341,10 @@ async function writeStreamed(
|
||||
);
|
||||
continue;
|
||||
}
|
||||
for (const reasoning of choice.reasoningChunks ??
|
||||
(choice.reasoning ? [choice.reasoning] : [])) {
|
||||
send(chunk(choice.index, { reasoning_content: reasoning }));
|
||||
}
|
||||
for (const [contentIndex, content] of (
|
||||
choice.contentChunks ?? []
|
||||
).entries()) {
|
||||
@@ -403,6 +420,8 @@ function responseChoices(message: FakeOpenAIResponse): FakeOpenAIChoice[] {
|
||||
index: 0,
|
||||
content: message.content,
|
||||
contentChunks: message.contentChunks,
|
||||
reasoning: message.reasoning,
|
||||
reasoningChunks: message.reasoningChunks,
|
||||
errorContent: message.errorContent,
|
||||
toolCalls: message.toolCalls,
|
||||
finishReason: message.finishReason,
|
||||
@@ -411,6 +430,12 @@ function responseChoices(message: FakeOpenAIResponse): FakeOpenAIChoice[] {
|
||||
);
|
||||
}
|
||||
|
||||
function choiceReasoning(
|
||||
choice: Pick<FakeOpenAIChoice, 'reasoning' | 'reasoningChunks'>,
|
||||
): string | undefined {
|
||||
return choice.reasoningChunks?.join('') ?? choice.reasoning;
|
||||
}
|
||||
|
||||
function finishReason(
|
||||
message: Pick<FakeOpenAIChoice, 'finishReason' | 'toolCalls'>,
|
||||
): string {
|
||||
|
||||
@@ -2615,7 +2615,7 @@ describe('llm.tsx OpenTUI renderer dispatch', () => {
|
||||
|
||||
// Drives main() to the renderer dispatch: interactive config, no
|
||||
// relaunch, TTY stdin — the same harness the kitty-protocol tests use.
|
||||
const interactiveMainSetup = async () => {
|
||||
const interactiveMainSetup = async (screenReader = false) => {
|
||||
const { loadCliConfig, parseArguments } = await import(
|
||||
'./config/config.js'
|
||||
);
|
||||
@@ -2643,7 +2643,7 @@ describe('llm.tsx OpenTUI renderer dispatch', () => {
|
||||
waitForMcpReady: vi.fn().mockResolvedValue(undefined),
|
||||
getIdeMode: () => false,
|
||||
getExperimentalZedIntegration: () => false,
|
||||
getScreenReader: () => false,
|
||||
getScreenReader: () => screenReader,
|
||||
getMemoryFileCount: () => 0,
|
||||
getWarnings: () => [],
|
||||
isSafeMode: () => false,
|
||||
@@ -2765,6 +2765,23 @@ describe('llm.tsx OpenTUI renderer dispatch', () => {
|
||||
|
||||
expect(mockStartPostRenderPrefetches).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('hands the screen-reader flag to the renderer gate', async () => {
|
||||
await interactiveMainSetup(/* screenReader */ true);
|
||||
selectOpentui(false);
|
||||
mockStartOpenTuiUI.mockResolvedValue(false);
|
||||
|
||||
await main();
|
||||
|
||||
// The gate is what keeps a screen-reader session on ink; dropping this
|
||||
// argument would silently serve OpenTUI, which has no SR render path.
|
||||
expect(mockSelectTuiRenderer).toHaveBeenLastCalledWith(
|
||||
undefined,
|
||||
undefined,
|
||||
expect.anything(),
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('llm.tsx main function kitty protocol', () => {
|
||||
|
||||
@@ -1327,7 +1327,12 @@ export async function main() {
|
||||
const { selectTuiRenderer, TUI_RENDERER_STRICT_ENV_VAR } = await import(
|
||||
'./ui/opentui/renderer-selection.js'
|
||||
);
|
||||
const selection = selectTuiRenderer();
|
||||
const selection = selectTuiRenderer(
|
||||
undefined,
|
||||
undefined,
|
||||
process.env,
|
||||
config.getScreenReader(),
|
||||
);
|
||||
if (selection.renderer === 'opentui') {
|
||||
try {
|
||||
const { startOpenTuiUI } = await import(
|
||||
|
||||
@@ -185,11 +185,11 @@ function ApiKeyStep({
|
||||
|
||||
const MODEL_DESCRIPTION_COLUMN = 28;
|
||||
const MODALITY_DISPLAY_ORDER = ['image', 'video', 'audio', 'pdf'] as const;
|
||||
const MODEL_CUSTOM_INPUT_FOCUS_INDEX = -2;
|
||||
const MODEL_SEARCH_INPUT_FOCUS_INDEX = -1;
|
||||
const MAX_MODELS_TO_SHOW = 8;
|
||||
export const MODEL_CUSTOM_INPUT_FOCUS_INDEX = -2;
|
||||
export const MODEL_SEARCH_INPUT_FOCUS_INDEX = -1;
|
||||
export const MAX_MODELS_TO_SHOW = 8;
|
||||
|
||||
interface ModelOption {
|
||||
export interface ModelOption {
|
||||
key: string;
|
||||
value: string;
|
||||
label: string;
|
||||
@@ -197,7 +197,7 @@ interface ModelOption {
|
||||
|
||||
type ModelRecommendationSource = 'provider' | 'fallback';
|
||||
|
||||
function formatModelOptionLabel(model: ModelSpec): string {
|
||||
export function formatModelOptionLabel(model: ModelSpec): string {
|
||||
const details: string[] = [];
|
||||
if (model.contextWindowSize) {
|
||||
details.push(`${model.contextWindowSize.toLocaleString('en-US')} tokens`);
|
||||
@@ -213,7 +213,7 @@ function formatModelOptionLabel(model: ModelSpec): string {
|
||||
return `${model.id.padEnd(MODEL_DESCRIPTION_COLUMN)}${suffix}`;
|
||||
}
|
||||
|
||||
function modelOptionSearchText(item: ModelOption): string {
|
||||
export function modelOptionSearchText(item: ModelOption): string {
|
||||
return `${item.key} ${item.label} ${item.value}`.toLowerCase();
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ interface EffortDialogProps {
|
||||
efforts?: readonly ReasoningEffort[];
|
||||
}
|
||||
|
||||
const EFFORT_DESCRIPTIONS: Record<ReasoningEffort, string> = {
|
||||
export const EFFORT_DESCRIPTIONS: Record<ReasoningEffort, string> = {
|
||||
low: 'Fastest and cheapest; least reasoning.',
|
||||
medium: 'Balanced speed, cost, and reasoning.',
|
||||
high: 'Default — strong reasoning for hard tasks.',
|
||||
|
||||
@@ -51,7 +51,7 @@ interface SuggestionsDisplayProps {
|
||||
onSelectCategory?: (category: SuggestionCategory | 'all') => void;
|
||||
}
|
||||
|
||||
function categoryLabel(cat: SuggestionCategory | 'all'): string {
|
||||
export function categoryLabel(cat: SuggestionCategory | 'all'): string {
|
||||
switch (cat) {
|
||||
case 'all':
|
||||
return t('All');
|
||||
|
||||
@@ -31,10 +31,7 @@ import {
|
||||
formatVisionBridgeNoticeDisplay,
|
||||
isVisionBridgeNoticeDisplay,
|
||||
} from '@qwen-code/qwen-code-core/services/visionBridge/vision-bridge-service.js';
|
||||
import {
|
||||
ToolNames,
|
||||
ToolNamesMigration,
|
||||
} from '@qwen-code/qwen-code-core/tools/tool-names.js';
|
||||
import { AGENT_TOOL_NAMES } from '../../utils/agent-tool-names.js';
|
||||
import { isTerminalImageDisplay } from '@qwen-code/qwen-code-core/tools/tools.js';
|
||||
import { ToolConfirmationMessage } from './ToolConfirmationMessage.js';
|
||||
import { PlanSummaryDisplay } from '../PlanSummaryDisplay.js';
|
||||
@@ -65,17 +62,6 @@ import { ToolElapsedTime } from '../shared/ToolElapsedTime.js';
|
||||
import { TerminalImage } from '../TerminalImage.js';
|
||||
import { formatInlineImageOverflow } from '../../utils/inline-image-parts.js';
|
||||
|
||||
// Names that resolve to the agent tool: the canonical name plus whatever
|
||||
// legacy request aliases core's migration map declares (e.g. 'task').
|
||||
// Tool-usage stats key on the raw request name, so the scrollback
|
||||
// sub-agent count must accept all of them.
|
||||
const AGENT_TOOL_NAMES: ReadonlySet<string> = new Set([
|
||||
ToolNames.AGENT,
|
||||
...Object.entries(ToolNamesMigration)
|
||||
.filter(([, canonical]) => canonical === ToolNames.AGENT)
|
||||
.map(([legacy]) => legacy),
|
||||
]);
|
||||
|
||||
// How many of the subagent's prior tool calls to list above an approval
|
||||
// prompt — enough to show what led up to the request without pushing the
|
||||
// confirmation itself off-screen.
|
||||
|
||||
@@ -6,11 +6,7 @@
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { generateSessionRecap, type Config } from '@qwen-code/qwen-code-core';
|
||||
import type {
|
||||
HistoryItem,
|
||||
HistoryItemAwayRecap,
|
||||
HistoryItemWithoutId,
|
||||
} from '../types.js';
|
||||
import type { HistoryItemAwayRecap, HistoryItemWithoutId } from '../types.js';
|
||||
|
||||
const DEFAULT_AWAY_THRESHOLD_MINUTES = 5;
|
||||
|
||||
@@ -23,6 +19,15 @@ const DEFAULT_AWAY_THRESHOLD_MINUTES = 5;
|
||||
const MIN_USER_MESSAGES_TO_FIRE = 3;
|
||||
const MIN_USER_MESSAGES_SINCE_LAST_RECAP = 2;
|
||||
|
||||
/**
|
||||
* The transcript rows the dedup gate reads. ink passes its own history items;
|
||||
* the OpenTUI leg maps its live transcript onto the same two fields.
|
||||
*/
|
||||
export interface RecapGateItem {
|
||||
type: string;
|
||||
sentToModel?: boolean;
|
||||
}
|
||||
|
||||
export interface UseAwaySummaryOptions {
|
||||
enabled: boolean;
|
||||
config: Config | null;
|
||||
@@ -34,7 +39,7 @@ export interface UseAwaySummaryOptions {
|
||||
* the dedup gate; not added to the effect's deps so it doesn't re-fire
|
||||
* on every history change.
|
||||
*/
|
||||
history: HistoryItem[];
|
||||
history: readonly RecapGateItem[];
|
||||
/**
|
||||
* Minutes the terminal must be blurred before an auto-recap fires on
|
||||
* the next focus-in. Falsy / non-positive values fall back to the
|
||||
@@ -47,7 +52,7 @@ export interface UseAwaySummaryOptions {
|
||||
* Whether enough new user activity has happened since the last recap to
|
||||
* justify another one. Mirrors Claude Code's `Ic1` gate.
|
||||
*/
|
||||
function shouldFireRecap(history: HistoryItem[]): boolean {
|
||||
function shouldFireRecap(history: readonly RecapGateItem[]): boolean {
|
||||
let userMessageCount = 0;
|
||||
let lastRecapIndex = -1;
|
||||
for (let i = 0; i < history.length; i++) {
|
||||
|
||||
@@ -223,7 +223,6 @@ describe('streamingModel', () => {
|
||||
const task = selectItemById(reduceAll(events), 's1');
|
||||
expect(task).toMatchObject({
|
||||
kind: 'task',
|
||||
done: false,
|
||||
progress: ['three', 'four', 'five'],
|
||||
});
|
||||
});
|
||||
@@ -239,7 +238,7 @@ describe('streamingModel', () => {
|
||||
const after = reduceAll([
|
||||
start,
|
||||
{ type: 'task-progress', id: 'ghost', line: 'nope' },
|
||||
{ type: 'task-end', id: 'ghost', tools: 1, seconds: 1, tokens: '1' },
|
||||
{ type: 'task-end', id: 'ghost' },
|
||||
]);
|
||||
expect(after.items).toEqual(before.items);
|
||||
});
|
||||
@@ -250,7 +249,6 @@ describe('streamingModel', () => {
|
||||
{ type: 'task-progress', id: 's1', line: 'stale' },
|
||||
{ type: 'task-start', id: 's1', name: 'n2', description: 'd2' },
|
||||
{ type: 'task-progress', id: 's1', line: 'fresh' },
|
||||
{ type: 'task-end', id: 's1', tools: 1, seconds: 1, tokens: '1' },
|
||||
]);
|
||||
expect(
|
||||
selectItems(state).filter((item) => item.kind === 'task'),
|
||||
@@ -258,11 +256,10 @@ describe('streamingModel', () => {
|
||||
expect(selectItemById(state, 's1')).toMatchObject({
|
||||
name: 'n2',
|
||||
progress: ['fresh'],
|
||||
done: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('closes the task with formatted stats', () => {
|
||||
it('drops the task card once the subagent settles', () => {
|
||||
const state = reduceAll([
|
||||
{
|
||||
type: 'task-start',
|
||||
@@ -271,23 +268,10 @@ describe('streamingModel', () => {
|
||||
description: 'bench ink frames',
|
||||
},
|
||||
{ type: 'task-progress', id: 's1', line: '↳ done' },
|
||||
{
|
||||
type: 'task-end',
|
||||
id: 's1',
|
||||
tools: 3,
|
||||
seconds: 12.4,
|
||||
tokens: '2.1k',
|
||||
},
|
||||
{ type: 'task-end', id: 's1' },
|
||||
]);
|
||||
expect(selectItemById(state, 's1')).toEqual({
|
||||
kind: 'task',
|
||||
id: 's1',
|
||||
name: 'researcher',
|
||||
description: 'bench ink frames',
|
||||
progress: ['↳ done'],
|
||||
done: true,
|
||||
stats: '3 tools · 12.4s · 2.1k tokens',
|
||||
});
|
||||
expect(selectItems(state)).toHaveLength(0);
|
||||
expect(selectItemById(state, 's1')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -334,13 +318,7 @@ describe('streamingModel', () => {
|
||||
description: 'verify',
|
||||
},
|
||||
{ type: 'task-progress', id: 's1', line: 'step' },
|
||||
{
|
||||
type: 'task-end',
|
||||
id: 's1',
|
||||
tools: 1,
|
||||
seconds: 2,
|
||||
tokens: '0.1k',
|
||||
},
|
||||
{ type: 'task-end', id: 's1' },
|
||||
{ type: 'text', delta: 'summary' },
|
||||
{ type: 'done' },
|
||||
]);
|
||||
@@ -349,11 +327,9 @@ describe('streamingModel', () => {
|
||||
'assistant',
|
||||
'tool',
|
||||
'assistant',
|
||||
'task',
|
||||
'assistant',
|
||||
]);
|
||||
expect(selectItemById(state, 't1')).toMatchObject({ done: true });
|
||||
expect(selectItemById(state, 's1')).toMatchObject({ done: true });
|
||||
expect(selectIsDone(state)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -386,7 +362,7 @@ describe('streamingModel', () => {
|
||||
],
|
||||
[
|
||||
{ type: 'task-start', id: 's1', name: 'n', description: 'd' },
|
||||
{ type: 'task-end', id: 's1', tools: 1, seconds: 1, tokens: '1' },
|
||||
{ type: 'task-end', id: 's1' },
|
||||
],
|
||||
// closeTrailingAssistant rewrites the streaming assistant when a
|
||||
// start/user/done event lands — pin the rewrite through a captured
|
||||
|
||||
@@ -35,13 +35,7 @@ export type StreamEvent =
|
||||
| { type: 'tool-end'; id: string; success: boolean; summary: string }
|
||||
| { type: 'task-start'; id: string; name: string; description: string }
|
||||
| { type: 'task-progress'; id: string; line: string }
|
||||
| {
|
||||
type: 'task-end';
|
||||
id: string;
|
||||
tools: number;
|
||||
seconds: number;
|
||||
tokens: string;
|
||||
}
|
||||
| { type: 'task-end'; id: string }
|
||||
| { type: 'done' };
|
||||
|
||||
export type HistoryItem =
|
||||
@@ -70,8 +64,6 @@ export type HistoryItem =
|
||||
name: string;
|
||||
description: string;
|
||||
progress: string[];
|
||||
done: boolean;
|
||||
stats?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -216,7 +208,6 @@ export function reduceStreamEvent(
|
||||
name: event.name,
|
||||
description: event.description,
|
||||
progress: [],
|
||||
done: false,
|
||||
};
|
||||
// Replayed-start reset; see the tool-start case for the reasoning.
|
||||
const index = findItemIndex(items, 'task', event.id);
|
||||
@@ -243,14 +234,7 @@ export function reduceStreamEvent(
|
||||
}
|
||||
case 'task-end': {
|
||||
const index = findItemIndex(items, 'task', event.id);
|
||||
if (index >= 0) {
|
||||
const task = items[index] as Extract<HistoryItem, { kind: 'task' }>;
|
||||
items[index] = {
|
||||
...task,
|
||||
done: true,
|
||||
stats: `${event.tools} tools · ${event.seconds}s · ${event.tokens} tokens`,
|
||||
};
|
||||
}
|
||||
if (index >= 0) items.splice(index, 1);
|
||||
break;
|
||||
}
|
||||
case 'done': {
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
// @vitest-environment jsdom
|
||||
|
||||
/**
|
||||
* Hook-level tests for the mirror every dialog list reads inside a key burst:
|
||||
* useBatchSafeCursor for a numeric cursor, useBatchSafeState for any other
|
||||
* value.
|
||||
*/
|
||||
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { useBatchSafeCursor, useBatchSafeState } from './batch-cursor.js';
|
||||
|
||||
describe('useBatchSafeCursor', () => {
|
||||
it('publishes each write to the mirror before the next one reads it', () => {
|
||||
const { result } = renderHook(() => useBatchSafeCursor());
|
||||
// One React batch, the way the renderer delivers a held arrow: no
|
||||
// re-render lands between the writes.
|
||||
act(() => {
|
||||
result.current.setCursor(result.current.cursorRef.current + 1);
|
||||
result.current.setCursor(result.current.cursorRef.current + 1);
|
||||
result.current.setCursor(result.current.cursorRef.current + 1);
|
||||
});
|
||||
expect(result.current.cursor).toBe(3);
|
||||
});
|
||||
|
||||
it('honours a lazy initial value', () => {
|
||||
const { result } = renderHook(() => useBatchSafeCursor(() => 7));
|
||||
expect(result.current.cursor).toBe(7);
|
||||
expect(result.current.cursorRef.current).toBe(7);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useBatchSafeState', () => {
|
||||
it('publishes a non-numeric write to the mirror before the next one reads it', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useBatchSafeState<'a' | 'b' | 'c'>('a'),
|
||||
);
|
||||
const next = { a: 'b', b: 'c', c: 'a' } as const;
|
||||
act(() => {
|
||||
result.current.setValue(next[result.current.ref.current]);
|
||||
result.current.setValue(next[result.current.ref.current]);
|
||||
});
|
||||
// Stepping off the state value instead would stop at 'b': both writes
|
||||
// would read what the render before the burst held.
|
||||
expect(result.current.value).toBe('c');
|
||||
});
|
||||
|
||||
it('honours a lazy initial value', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useBatchSafeState<ReadonlySet<string>>(() => new Set(['a'])),
|
||||
);
|
||||
expect([...result.current.value]).toEqual(['a']);
|
||||
expect([...result.current.ref.current]).toEqual(['a']);
|
||||
});
|
||||
|
||||
it('pulls a drifted mirror back to the value on the next render', () => {
|
||||
const { result, rerender } = renderHook(() => useBatchSafeState('keep'));
|
||||
act(() => {
|
||||
result.current.ref.current = 'drifted';
|
||||
});
|
||||
expect(result.current.ref.current).toBe('drifted');
|
||||
|
||||
rerender();
|
||||
|
||||
expect(result.current.ref.current).toBe('keep');
|
||||
expect(result.current.value).toBe('keep');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* State that key handlers can read synchronously.
|
||||
*
|
||||
* The renderer hands a burst of keys — a held arrow, or arrows followed by
|
||||
* Enter — to the handler registered by the last render. A handler reading the
|
||||
* state value acts on the pre-burst value for every key in it: the highlight
|
||||
* moves once for the whole burst, Enter commits the row the arrows had not
|
||||
* reached yet, and a text buffer submits what it held before the burst typed
|
||||
* into it. Handlers read the ref; JSX renders the value.
|
||||
*/
|
||||
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
|
||||
export function useBatchSafeCursor(initial: number | (() => number) = 0) {
|
||||
const [cursor, setCursorState] = useState(initial);
|
||||
const cursorRef = useRef(cursor);
|
||||
cursorRef.current = cursor;
|
||||
const setCursor = useCallback((index: number) => {
|
||||
cursorRef.current = index;
|
||||
setCursorState(index);
|
||||
}, []);
|
||||
return { cursor, cursorRef, setCursor };
|
||||
}
|
||||
|
||||
/** The same double write for non-numeric state: view modes, check sets, text. */
|
||||
export function useBatchSafeState<T>(initial: T | (() => T)) {
|
||||
const [value, setValueState] = useState<T>(initial);
|
||||
const ref = useRef(value);
|
||||
ref.current = value;
|
||||
const setValue = useCallback((next: T) => {
|
||||
ref.current = next;
|
||||
setValueState(next);
|
||||
}, []);
|
||||
return { value, ref, setValue };
|
||||
}
|
||||
@@ -123,14 +123,16 @@ describe('routeDialogToOpenTui (ink dialog-switch parity)', () => {
|
||||
it('maps each dialog kind onto its exact OpenTUI target', () => {
|
||||
// Pins the mapping itself, not just its existence: mis-routing one
|
||||
// dialog onto another (e.g. theme → settings) must fail here.
|
||||
const targets: Array<[InkDialogKind, string]> = [
|
||||
const targets: Array<[InkDialogKind, string, object?]> = [
|
||||
['help', 'help'],
|
||||
['theme', 'theme'],
|
||||
['editor', 'editor'],
|
||||
['settings', 'settings'],
|
||||
['statusline', 'statusline'],
|
||||
['memory', 'memory'],
|
||||
['auth', 'auth'],
|
||||
// A command-opened auth dialog records its result; the boot auth-error
|
||||
// open does not (ink useAuth's openedViaCommandRef).
|
||||
['auth', 'auth', { openedViaCommand: true }],
|
||||
['trust', 'trust'],
|
||||
['permissions', 'permissions'],
|
||||
['approval-mode', 'approval-mode'],
|
||||
@@ -145,13 +147,13 @@ describe('routeDialogToOpenTui (ink dialog-switch parity)', () => {
|
||||
['diff', 'diff'],
|
||||
['stats', 'stats'],
|
||||
];
|
||||
for (const [dialog, target] of targets) {
|
||||
for (const [dialog, target, extras] of targets) {
|
||||
expect(
|
||||
routeDialogToOpenTui({
|
||||
type: 'dialog',
|
||||
dialog,
|
||||
} as OpenDialogActionReturn),
|
||||
).toEqual({ dialog: target });
|
||||
).toEqual({ dialog: target, ...extras });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ export type OpenTuiDialogRequest =
|
||||
| { dialog: 'settings' }
|
||||
| { dialog: 'statusline' }
|
||||
| { dialog: 'memory' }
|
||||
| { dialog: 'auth'; initialError?: string }
|
||||
| { dialog: 'auth'; initialError?: string; openedViaCommand?: boolean }
|
||||
| { dialog: 'trust' }
|
||||
| { dialog: 'permissions' }
|
||||
| { dialog: 'approval-mode' }
|
||||
@@ -88,7 +88,9 @@ export function routeDialogToOpenTui(
|
||||
case 'memory':
|
||||
return { dialog: 'memory' };
|
||||
case 'auth':
|
||||
return { dialog: 'auth' };
|
||||
// ink records the /auth result only when the command opened the dialog
|
||||
// (useAuth's openedViaCommandRef), never for the boot auth-error open.
|
||||
return { dialog: 'auth', openedViaCommand: true };
|
||||
case 'trust':
|
||||
return { dialog: 'trust' };
|
||||
case 'permissions':
|
||||
|
||||
@@ -63,6 +63,8 @@ import { extensionComponentsSummary } from '../../services/extension-components-
|
||||
import { getErrorMessage } from '../../utils/errors.js';
|
||||
import { getToolInvalidReasons, isToolValid } from '../components/mcp/utils.js';
|
||||
import { themeManager, AUTO_THEME_NAME } from '../themes/theme-manager.js';
|
||||
import { applyOpenTuiTheme } from './theme.js';
|
||||
import { getActiveOpenTuiTheme } from './theme-parity.js';
|
||||
import {
|
||||
isSelectableVoiceModel,
|
||||
formatUnsupportedVoiceModelMessage,
|
||||
@@ -587,6 +589,7 @@ export function applyThemeSelection(
|
||||
}
|
||||
const effective = settings.merged.ui?.theme;
|
||||
themeManager.setActiveTheme(effective ?? AUTO_THEME_NAME);
|
||||
applyOpenTuiTheme(getActiveOpenTuiTheme());
|
||||
return { applied: themeName };
|
||||
}
|
||||
|
||||
|
||||
@@ -38,8 +38,9 @@ import {
|
||||
import { formatDuration } from '../utils/formatters.js';
|
||||
import { getArenaStatusLabel } from '../utils/displayUtils.js';
|
||||
import { toOriginalKey } from './key-map.js';
|
||||
import { nextEnabledIndex } from './dialogs-misc.js';
|
||||
import { findNextEnabledIndex } from './dialogs-core.js';
|
||||
import { C } from './theme.js';
|
||||
import { useBatchSafeCursor, useBatchSafeState } from './batch-cursor.js';
|
||||
|
||||
export type ArenaDialogMode = 'start' | 'select' | 'stop' | 'status';
|
||||
|
||||
@@ -48,7 +49,7 @@ export interface OpenTuiArenaDialogProps {
|
||||
mode: ArenaDialogMode;
|
||||
onClose: () => void;
|
||||
/** Command-style chat messages (ink addItem parity). */
|
||||
notify: (text: string) => void;
|
||||
notify: (text: string, level?: 'info' | 'error') => void;
|
||||
/** ink handleArenaModelsSelected: fill the composer, keep it unsubmitted. */
|
||||
onFillInput?: (text: string) => void;
|
||||
}
|
||||
@@ -144,8 +145,14 @@ function ArenaStart({ config, onClose, onFillInput }: OpenTuiArenaDialogProps) {
|
||||
};
|
||||
});
|
||||
}, [config]);
|
||||
const [cursor, setCursor] = useState(0);
|
||||
const [checked, setChecked] = useState<ReadonlySet<string>>(new Set());
|
||||
const { cursor, cursorRef, setCursor } = useBatchSafeCursor();
|
||||
// Keystrokes of one burst are handled against the render that registered the
|
||||
// handler, whose `checked` set is already stale by the second Space.
|
||||
const {
|
||||
value: checked,
|
||||
ref: checkedRef,
|
||||
setValue: setChecked,
|
||||
} = useBatchSafeState<ReadonlySet<string>>(new Set());
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const hasDisabledQwenOauth = modelItems.some((m) => m.disabled);
|
||||
@@ -158,24 +165,21 @@ function ArenaStart({ config, onClose, onFillInput }: OpenTuiArenaDialogProps) {
|
||||
if (o.name === 'escape') {
|
||||
onClose();
|
||||
} else if (o.name === 'up' || o.name === 'down') {
|
||||
const d = o.name === 'up' ? -1 : 1;
|
||||
setCursor((c) => nextEnabledIndex(modelItems, c, d));
|
||||
setCursor(findNextEnabledIndex(modelItems, cursorRef.current, o.name));
|
||||
} else if (o.name === 'space') {
|
||||
const item = modelItems[cursor];
|
||||
const item = modelItems[cursorRef.current];
|
||||
if (!item || item.disabled) return;
|
||||
setChecked((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(item.key)) next.delete(item.key);
|
||||
else next.add(item.key);
|
||||
return next;
|
||||
});
|
||||
const next = new Set(checkedRef.current);
|
||||
if (next.has(item.key)) next.delete(item.key);
|
||||
else next.add(item.key);
|
||||
setChecked(next);
|
||||
} else if (o.name === 'return') {
|
||||
if (checked.size < 2) {
|
||||
if (checkedRef.current.size < 2) {
|
||||
setError('Please select at least 2 models to start an Arena session.');
|
||||
return;
|
||||
}
|
||||
const values = modelItems
|
||||
.filter((m) => checked.has(m.key))
|
||||
.filter((m) => checkedRef.current.has(m.key))
|
||||
.map((m) => m.key);
|
||||
onFillInput?.(`/arena start --models ${values.join(',')} `);
|
||||
onClose();
|
||||
@@ -359,7 +363,7 @@ function ArenaStatus({ config, onClose }: OpenTuiArenaDialogProps) {
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
<text fg={C.dim}>{'─'.repeat(innerWidth)}</text>
|
||||
<text fg={C.borderDefault}>{'─'.repeat(innerWidth)}</text>
|
||||
{agents.length === 0 ? (
|
||||
<text fg={C.dim}>{'No agents registered yet.'}</text>
|
||||
) : (
|
||||
@@ -435,7 +439,11 @@ function ArenaStop({ config, onClose, notify }: OpenTuiArenaDialogProps) {
|
||||
desc: 'Keep worktrees and session files for later inspection',
|
||||
},
|
||||
];
|
||||
const [sel, setSel] = useState(preserveDefault ? 1 : 0);
|
||||
const {
|
||||
cursor: sel,
|
||||
cursorRef: selRef,
|
||||
setCursor: setSel,
|
||||
} = useBatchSafeCursor(preserveDefault ? 1 : 0);
|
||||
|
||||
const runStop = async (action: StopAction) => {
|
||||
if (processing) return;
|
||||
@@ -443,7 +451,7 @@ function ArenaStop({ config, onClose, notify }: OpenTuiArenaDialogProps) {
|
||||
onClose();
|
||||
const mgr = config?.getArenaManager?.();
|
||||
if (!mgr) {
|
||||
notify('✗ No running Arena session found.');
|
||||
notify('No running Arena session found.', 'error');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -470,7 +478,8 @@ function ArenaStop({ config, onClose, notify }: OpenTuiArenaDialogProps) {
|
||||
);
|
||||
} catch (error) {
|
||||
notify(
|
||||
`✗ Failed to stop Arena session: ${error instanceof Error ? error.message : String(error)}`,
|
||||
`Failed to stop Arena session: ${error instanceof Error ? error.message : String(error)}`,
|
||||
'error',
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -481,9 +490,9 @@ function ArenaStop({ config, onClose, notify }: OpenTuiArenaDialogProps) {
|
||||
if (o.name === 'escape') {
|
||||
onClose();
|
||||
} else if (o.name === 'up' || o.name === 'down') {
|
||||
setSel((s) => (s === 0 ? 1 : 0));
|
||||
setSel(selRef.current === 0 ? 1 : 0);
|
||||
} else if (o.name === 'return') {
|
||||
void runStop(items[sel]?.key ?? 'cleanup');
|
||||
void runStop(items[selRef.current]?.key ?? 'cleanup');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -497,19 +506,14 @@ function ArenaStop({ config, onClose, notify }: OpenTuiArenaDialogProps) {
|
||||
</box>
|
||||
<box marginTop={1} flexDirection="column">
|
||||
{items.map((it, i) => (
|
||||
<box key={it.key} flexDirection="column">
|
||||
<box flexDirection="row">
|
||||
<text fg={i === sel ? C.accent : C.dim}>
|
||||
{i === sel ? '› ' : ' '}
|
||||
</text>
|
||||
<text
|
||||
fg={i === sel ? C.text : C.dim}
|
||||
attributes={i === sel ? 1 : 0}
|
||||
>
|
||||
{it.label}
|
||||
<box key={it.key} flexDirection="row" alignItems="flex-start">
|
||||
<box minWidth={2} flexShrink={0}>
|
||||
<text fg={i === sel ? C.green : C.text}>
|
||||
{i === sel ? '›' : ' '}
|
||||
</text>
|
||||
</box>
|
||||
<box paddingLeft={2}>
|
||||
<box flexDirection="column" flexGrow={1}>
|
||||
<text fg={i === sel ? C.green : C.text}>{it.label}</text>
|
||||
<text fg={C.dim}>{it.desc}</text>
|
||||
</box>
|
||||
</box>
|
||||
@@ -616,7 +620,11 @@ function ArenaSelect({ config, onClose, notify }: OpenTuiArenaDialogProps) {
|
||||
const manager = config?.getArenaManager?.() ?? null;
|
||||
const agents = useMemo(() => manager?.getAgentStates() ?? [], [manager]);
|
||||
const result = manager?.getResult();
|
||||
const [sel, setSel] = useState(() =>
|
||||
const {
|
||||
cursor: sel,
|
||||
cursorRef: selRef,
|
||||
setCursor: setSel,
|
||||
} = useBatchSafeCursor(() =>
|
||||
Math.max(
|
||||
0,
|
||||
agents.findIndex((a) => isSuccessStatus(a.status)),
|
||||
@@ -672,7 +680,7 @@ function ArenaSelect({ config, onClose, notify }: OpenTuiArenaDialogProps) {
|
||||
onClose();
|
||||
const mgr = config?.getArenaManager?.();
|
||||
if (!mgr) {
|
||||
notify('✗ No arena session found. Start one with /arena start.');
|
||||
notify('No arena session found. Start one with /arena start.', 'error');
|
||||
return;
|
||||
}
|
||||
const agent =
|
||||
@@ -682,14 +690,18 @@ function ArenaSelect({ config, onClose, notify }: OpenTuiArenaDialogProps) {
|
||||
notify(`Applying changes from ${label}…`);
|
||||
const applyResult = await mgr.applyAgentResult(agentId);
|
||||
if (!applyResult.success) {
|
||||
notify(`✗ Failed to apply changes from ${label}: ${applyResult.error}`);
|
||||
notify(
|
||||
`Failed to apply changes from ${label}: ${applyResult.error}`,
|
||||
'error',
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await config?.cleanupArenaRuntime?.(true);
|
||||
} catch (err) {
|
||||
notify(
|
||||
`✗ Warning: failed to clean up arena resources: ${err instanceof Error ? err.message : String(err)}`,
|
||||
`Warning: failed to clean up arena resources: ${err instanceof Error ? err.message : String(err)}`,
|
||||
'error',
|
||||
);
|
||||
}
|
||||
notify(
|
||||
@@ -701,7 +713,7 @@ function ArenaSelect({ config, onClose, notify }: OpenTuiArenaDialogProps) {
|
||||
onClose();
|
||||
const mgr = config?.getArenaManager?.();
|
||||
if (!mgr) {
|
||||
notify('✗ No arena session found. Start one with /arena start.');
|
||||
notify('No arena session found. Start one with /arena start.', 'error');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -710,7 +722,8 @@ function ArenaSelect({ config, onClose, notify }: OpenTuiArenaDialogProps) {
|
||||
notify('Arena results discarded. All worktrees cleaned up.');
|
||||
} catch (err) {
|
||||
notify(
|
||||
`✗ Failed to clean up arena worktrees: ${err instanceof Error ? err.message : String(err)}`,
|
||||
`Failed to clean up arena worktrees: ${err instanceof Error ? err.message : String(err)}`,
|
||||
'error',
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -720,9 +733,9 @@ function ArenaSelect({ config, onClose, notify }: OpenTuiArenaDialogProps) {
|
||||
if (o.name === 'escape') {
|
||||
onClose();
|
||||
} else if (o.name === 'up' || o.name === 'down') {
|
||||
setSel((s) => nextEnabledIndex(rows, s, o.name === 'up' ? -1 : 1));
|
||||
setSel(findNextEnabledIndex(rows, selRef.current, o.name));
|
||||
} else if (o.name === 'return') {
|
||||
const row = rows[sel];
|
||||
const row = rows[selRef.current];
|
||||
if (row && !row.disabled) void applyWinner(row.key);
|
||||
} else if (!o.ctrl && !o.meta) {
|
||||
if (o.name === 'p') setShowPreview((v) => !v);
|
||||
@@ -759,35 +772,34 @@ function ArenaSelect({ config, onClose, notify }: OpenTuiArenaDialogProps) {
|
||||
</box>
|
||||
<box marginTop={1} flexDirection="column">
|
||||
{rows.map((row, i) => (
|
||||
<box key={row.key} flexDirection="column">
|
||||
<box flexDirection="row">
|
||||
<text fg={row.disabled ? C.dim : i === sel ? C.accent : C.dim}>
|
||||
{i === sel ? '› ' : ' '}
|
||||
</text>
|
||||
<text
|
||||
fg={row.disabled ? C.dim : i === sel ? C.text : C.dim}
|
||||
attributes={!row.disabled && i === sel ? 1 : 0}
|
||||
>
|
||||
{row.label}
|
||||
<box key={row.key} flexDirection="row" alignItems="flex-start">
|
||||
<box minWidth={2} flexShrink={0}>
|
||||
<text fg={i === sel ? C.green : C.text}>
|
||||
{i === sel ? '›' : ' '}
|
||||
</text>
|
||||
</box>
|
||||
<box paddingLeft={2} flexDirection="row">
|
||||
<text fg={row.status.color}>{row.status.text}</text>
|
||||
<text
|
||||
fg={C.dim}
|
||||
>{` · ${row.duration} · ${row.tokens} tokens`}</text>
|
||||
{row.fileCount > 0 && (
|
||||
<text fg={C.dim}>{` · ${row.fileCount} files`}</text>
|
||||
)}
|
||||
{(row.additions > 0 || row.deletions > 0) && (
|
||||
<>
|
||||
<text fg={C.dim}>{' · '}</text>
|
||||
<text fg={C.green}>{`+${row.additions}`}</text>
|
||||
<text fg={C.dim}>{'/'}</text>
|
||||
<text fg={C.red}>{`-${row.deletions}`}</text>
|
||||
<text fg={C.dim}>{' lines'}</text>
|
||||
</>
|
||||
)}
|
||||
<box flexDirection="column" flexGrow={1}>
|
||||
<text fg={row.disabled ? C.dim : i === sel ? C.green : C.text}>
|
||||
{row.label}
|
||||
</text>
|
||||
<box flexDirection="row">
|
||||
<text fg={row.status.color}>{row.status.text}</text>
|
||||
<text
|
||||
fg={C.dim}
|
||||
>{` · ${row.duration} · ${row.tokens} tokens`}</text>
|
||||
{row.fileCount > 0 && (
|
||||
<text fg={C.dim}>{` · ${row.fileCount} files`}</text>
|
||||
)}
|
||||
{(row.additions > 0 || row.deletions > 0) && (
|
||||
<>
|
||||
<text fg={C.dim}>{' · '}</text>
|
||||
<text fg={C.green}>{`+${row.additions}`}</text>
|
||||
<text fg={C.dim}>{'/'}</text>
|
||||
<text fg={C.red}>{`-${row.deletions}`}</text>
|
||||
<text fg={C.dim}>{' lines'}</text>
|
||||
</>
|
||||
)}
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
))}
|
||||
|
||||
@@ -362,6 +362,43 @@ describe('OpenTuiAuthDialog (#57 onboarding flow)', () => {
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('opens the main-menu entry the arrows of one read reached (#122)', async () => {
|
||||
renderDialog();
|
||||
const handler = lastKeyboardHandler();
|
||||
await act(async () => {
|
||||
handler(baseKeyEvent({ name: 'down', sequence: '\x1b[B' }));
|
||||
handler(baseKeyEvent({ name: 'down', sequence: '\x1b[B' }));
|
||||
handler(baseKeyEvent({ name: 'return', sequence: '\r' }));
|
||||
});
|
||||
// Two downs land on Custom Provider. The cursor the handler was registered
|
||||
// with still pointed at Alibaba ModelStudio, whose Enter opens the
|
||||
// access-method sub-menu instead.
|
||||
expect(screen.getByText('OpenAI-compatible')).toBeTruthy();
|
||||
expect(screen.queryByText(/Access Method/)).toBeNull();
|
||||
});
|
||||
|
||||
it('wraps the main-menu cursor from the first row to the last (#160)', async () => {
|
||||
renderDialog();
|
||||
// ink builds this list on DescriptiveRadioButtonSelect, whose useSelectionList
|
||||
// steps modulo the row count; a clamp would stay on Alibaba ModelStudio and
|
||||
// Enter would open the access-method sub-menu instead.
|
||||
await press('up');
|
||||
await press('return');
|
||||
expect(screen.getByText('OpenAI-compatible')).toBeTruthy();
|
||||
expect(screen.queryByText(/Access Method/)).toBeNull();
|
||||
});
|
||||
|
||||
it('wraps the main-menu cursor from the last row to the first (#160)', async () => {
|
||||
renderDialog();
|
||||
await press('down');
|
||||
await press('down');
|
||||
await press('down');
|
||||
await press('return');
|
||||
expect(
|
||||
screen.getByText('Alibaba ModelStudio · Access Method'),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('keeps authentication open when only service models were saved', async () => {
|
||||
const servicePlan = coreRuntime.buildInstallPlan(
|
||||
coreRuntime.minimaxProvider,
|
||||
@@ -465,13 +502,12 @@ describe('OpenTuiAuthDialog (#57 onboarding flow)', () => {
|
||||
await press('down');
|
||||
await press('return'); // main: CUSTOM_PROVIDER → protocol
|
||||
await press('return'); // protocol: OpenAI-compatible → API
|
||||
// RadioList marks the cursor row with '› '.
|
||||
expect(
|
||||
screen.getByText('Responses').previousElementSibling?.textContent,
|
||||
).toBe('› ');
|
||||
expect(
|
||||
screen.getByText('Chat Completions').previousElementSibling?.textContent,
|
||||
).toBe(' ');
|
||||
// The parity RadioList gives the marker its own cell, so read it off the row.
|
||||
const markerOf = (label: string) =>
|
||||
screen.getByText(label).parentElement?.parentElement?.firstChild
|
||||
?.textContent;
|
||||
expect(markerOf('Responses')).toBe('›');
|
||||
expect(markerOf('Chat Completions')).toBe(' ');
|
||||
await press('return'); // API: keep the saved Responses route → baseUrl
|
||||
await typeText('https://gw.example/v1');
|
||||
await press('return'); // baseUrl → apiKey
|
||||
@@ -504,7 +540,27 @@ describe('OpenTuiAuthDialog (#57 onboarding flow)', () => {
|
||||
await press('return'); // empty submit → flow sets modelIdsError
|
||||
expect(screen.getByText(/Model IDs cannot be empty/)).toBeTruthy();
|
||||
// the error is non-fatal: the step stays mounted
|
||||
expect(screen.getByText(/Enter model IDs directly/)).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText(/Enter model IDs separated by commas/),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders ink's no-recommendations branch for a provider without models", async () => {
|
||||
renderDialog();
|
||||
await press('down');
|
||||
await press('down');
|
||||
await press('return'); // main: CUSTOM_PROVIDER → protocol
|
||||
await press('return'); // protocol: OpenAI-compatible → API selection
|
||||
await press('return'); // API: Chat Completions → baseUrl input
|
||||
await typeText('https://api.example.com/v1');
|
||||
await press('return'); // baseUrl → apiKey
|
||||
await typeText('sk-test');
|
||||
await press('return'); // apiKey → models
|
||||
// The row is composed of several spans (`> ` prefix plus the placeholder's
|
||||
// cursor cell), so match the line the branch paints rather than one node.
|
||||
expect(document.body.textContent).toContain('> model-id-1, model-id-2');
|
||||
expect(screen.queryByText('Recommended models')).toBeNull();
|
||||
expect(screen.queryByText('Search')).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the dialog open and shows the error when the plan fails', async () => {
|
||||
@@ -644,7 +700,9 @@ describe('bracketed-paste into dialog inputs (#57)', () => {
|
||||
).toBeTruthy();
|
||||
// the burst is what the wizard carries forward, not its last character
|
||||
await press('return'); // apiKey → models
|
||||
expect(screen.getByText(/Enter model IDs directly/)).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText(/Enter model IDs separated by commas/),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('submits the burst that shares one batch with its Enter', async () => {
|
||||
@@ -653,7 +711,9 @@ describe('bracketed-paste into dialog inputs (#57)', () => {
|
||||
// Before the fix the Enter read the flow's state, which no render had
|
||||
// refreshed yet, and the step refused the key it was showing; the submit now
|
||||
// reads the editor's live text, so the burst lands on the models step.
|
||||
expect(screen.getByText(/Enter model IDs directly/)).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText(/Enter model IDs separated by commas/),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('carries the URL typed in the same batch as its Enter to the review', async () => {
|
||||
@@ -819,7 +879,9 @@ describe('bracketed-paste into dialog inputs (#57)', () => {
|
||||
expect(screen.getByText('sk-pasted-key')).toBeTruthy();
|
||||
// the pasted key is what the wizard carries forward, not a lost paste
|
||||
await press('return'); // apiKey → models
|
||||
expect(screen.getByText(/Enter model IDs directly/)).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText(/Enter model IDs separated by commas/),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('keeps a pasted line break out of the field row', async () => {
|
||||
@@ -1113,6 +1175,7 @@ describe('recommended-model checkboxes out of one read (#113)', () => {
|
||||
|
||||
const SPACE = { name: 'space', sequence: ' ' };
|
||||
const ENTER = { name: 'return', sequence: '\r' };
|
||||
const DOWN = { name: 'down', sequence: '\x1b[B' };
|
||||
|
||||
/** Every key of one stdin read, dispatched without a render in between. */
|
||||
async function pressBatched(
|
||||
@@ -1125,22 +1188,31 @@ describe('recommended-model checkboxes out of one read (#113)', () => {
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk the DeepSeek wizard to the model-IDs step with the recommended list
|
||||
* holding focus. A custom provider ships no recommended list, so a preset is
|
||||
* Walk the DeepSeek wizard to the model-IDs step, where the custom-ID input
|
||||
* holds focus. A custom provider ships no recommended list, so a preset is
|
||||
* the only route to the checkboxes.
|
||||
*/
|
||||
async function runToRecommendedList(): Promise<void> {
|
||||
async function runToModelsStep(): Promise<void> {
|
||||
renderDialog();
|
||||
await press('down'); // main: THIRD_PARTY_PROVIDERS
|
||||
await press('return'); // → thirdparty-select, DeepSeek on top
|
||||
await press('return'); // DeepSeek → apiKey
|
||||
await typeText('sk-test');
|
||||
await press('return'); // apiKey → models (custom-ID input focused)
|
||||
}
|
||||
|
||||
async function runToRecommendedList(): Promise<void> {
|
||||
await runToModelsStep();
|
||||
await press('tab'); // → search field
|
||||
await press('tab'); // → recommended list, first row
|
||||
}
|
||||
|
||||
// Rows carry ink's formatted label (the id padded out to the description
|
||||
// column), so the id only ever matches as a prefix; the radio lives in a
|
||||
// sibling box one level up from the label.
|
||||
function recommendedRow(id: string): string {
|
||||
const row = screen.getByText(id).parentElement;
|
||||
const label = screen.getByText(new RegExp(`^${id}(\\s|$)`));
|
||||
const row = label.parentElement?.parentElement;
|
||||
if (!row) throw new Error(`the ${id} row is not mounted`);
|
||||
return row.textContent ?? '';
|
||||
}
|
||||
@@ -1169,4 +1241,28 @@ describe('recommended-model checkboxes out of one read (#113)', () => {
|
||||
build.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('lands the Space on the row the arrows of the same read reached', async () => {
|
||||
await runToModelsStep();
|
||||
// Custom-ID field → search field → first row, all out of one read. Read
|
||||
// from the render that armed the handler, the Space would still see the
|
||||
// custom-ID field and type a space into it instead of toggling a row.
|
||||
await pressBatched([DOWN, DOWN, SPACE]);
|
||||
expect(recommendedRow('deepseek-v4-pro')).toContain(ICON.CIRCLE_EMPTY);
|
||||
});
|
||||
|
||||
it('toggles the row an arrow of the same read moved to', async () => {
|
||||
await runToRecommendedList();
|
||||
await pressBatched([DOWN, SPACE]);
|
||||
expect(recommendedRow('deepseek-v4-pro')).toContain(ICON.RADIO_FILLED);
|
||||
expect(recommendedRow('deepseek-v4-flash')).toContain(ICON.CIRCLE_EMPTY);
|
||||
});
|
||||
|
||||
it('filters the recommended list from the search field one Tab away', async () => {
|
||||
await runToModelsStep();
|
||||
await press('tab'); // custom-ID input → search field
|
||||
await typeText('flash');
|
||||
expect(screen.queryByText(/^deepseek-v4-pro(\s|$)/)).toBeNull();
|
||||
expect(recommendedRow('deepseek-v4-flash')).toContain(ICON.RADIO_FILLED);
|
||||
});
|
||||
});
|
||||
@@ -15,8 +15,7 @@
|
||||
* the same write path useAuth.handleProviderSubmit drives) mirror the ink
|
||||
* implementation; only the view layer is OpenTUI.
|
||||
*
|
||||
* Known simplifications vs ink (recorded in the gap tracker):
|
||||
* - the models step omits the recommended-list search box (list is short);
|
||||
* Known simplification vs ink (recorded in the gap tracker):
|
||||
* - documentation/TOS links render as plain text (no OSC 8 in dialogs).
|
||||
*/
|
||||
|
||||
@@ -60,13 +59,23 @@ import {
|
||||
type SetupStep,
|
||||
} from '../auth/useProviderSetupFlow.js';
|
||||
import { normalizeModelIds } from '../auth/useAuth.js';
|
||||
import {
|
||||
MAX_MODELS_TO_SHOW,
|
||||
MODEL_CUSTOM_INPUT_FOCUS_INDEX,
|
||||
MODEL_SEARCH_INPUT_FOCUS_INDEX,
|
||||
formatModelOptionLabel,
|
||||
modelOptionSearchText,
|
||||
type ModelOption,
|
||||
} from '../auth/ProviderSetupSteps.js';
|
||||
import { toOriginalKey } from './key-map.js';
|
||||
import { isPrintableKeyInput } from './input-prompt-key.js';
|
||||
import { normalizePastedText } from './input-prompt-model.js';
|
||||
import { sanitizeTerminalText } from '../utils/textUtils.js';
|
||||
import { caretSpans, useLineEdit } from './line-edit.js';
|
||||
import { Shell } from './dialogs-misc.js';
|
||||
import { findNextEnabledIndex } from './dialogs-core.js';
|
||||
import { C } from './theme.js';
|
||||
import { useBatchSafeCursor, useBatchSafeState } from './batch-cursor.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types & static data (AuthDialog parity)
|
||||
@@ -193,22 +202,19 @@ function RadioList({ items, cursor }: { items: RadioItem[]; cursor: number }) {
|
||||
flexDirection="column"
|
||||
marginTop={i === 0 ? 0 : 1}
|
||||
>
|
||||
<box flexDirection="row">
|
||||
<text fg={selected ? C.accent : C.dim}>
|
||||
{selected ? '› ' : ' '}
|
||||
</text>
|
||||
<text
|
||||
fg={selected ? C.text : C.dim}
|
||||
attributes={selected ? 1 : 0}
|
||||
>
|
||||
{item.label}
|
||||
</text>
|
||||
</box>
|
||||
{item.description ? (
|
||||
<box flexDirection="row" paddingLeft={2}>
|
||||
<text fg={C.dim}>{item.description}</text>
|
||||
<box flexDirection="row" alignItems="flex-start">
|
||||
<box minWidth={2} flexShrink={0}>
|
||||
<text fg={selected ? C.green : C.text}>
|
||||
{selected ? '›' : ' '}
|
||||
</text>
|
||||
</box>
|
||||
) : null}
|
||||
<box flexDirection="column" flexGrow={1}>
|
||||
<text fg={selected ? C.green : C.text}>{item.label}</text>
|
||||
{item.description ? (
|
||||
<text fg={C.dim}>{item.description}</text>
|
||||
) : null}
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
);
|
||||
})}
|
||||
@@ -263,14 +269,17 @@ function InputLine({
|
||||
caret,
|
||||
placeholder,
|
||||
active,
|
||||
marginTop = 1,
|
||||
}: {
|
||||
value: string;
|
||||
caret: number;
|
||||
placeholder?: string;
|
||||
active?: boolean;
|
||||
marginTop?: number;
|
||||
}) {
|
||||
return (
|
||||
<box flexDirection="row" marginTop={1} paddingLeft={1}>
|
||||
<box flexDirection="row" marginTop={marginTop}>
|
||||
<text fg={C.accent}>{'> '}</text>
|
||||
<FieldText
|
||||
value={value}
|
||||
caret={caret}
|
||||
@@ -333,7 +342,7 @@ function ProtocolStep({ flow }: { flow: ProviderSetupFlow }) {
|
||||
protocolOpts.includes(p.value as AuthType),
|
||||
);
|
||||
}, [provider]);
|
||||
const [cursor, setCursor] = useState(
|
||||
const { cursor, cursorRef, setCursor } = useBatchSafeCursor(() =>
|
||||
Math.max(
|
||||
0,
|
||||
items.findIndex((item) => item.value === flow.state.protocol),
|
||||
@@ -341,12 +350,10 @@ function ProtocolStep({ flow }: { flow: ProviderSetupFlow }) {
|
||||
);
|
||||
useKeyboard((key) => {
|
||||
const o = toOriginalKey(key);
|
||||
if (o.name === 'up') {
|
||||
setCursor((c) => Math.max(0, c - 1));
|
||||
} else if (o.name === 'down') {
|
||||
setCursor((c) => Math.min(items.length - 1, c + 1));
|
||||
if (o.name === 'up' || o.name === 'down') {
|
||||
setCursor(findNextEnabledIndex(items, cursorRef.current, o.name));
|
||||
} else if (o.name === 'return') {
|
||||
const item = items[cursor];
|
||||
const item = items[cursorRef.current];
|
||||
if (item) flow.selectProtocol(item.value as AuthType);
|
||||
}
|
||||
});
|
||||
@@ -403,21 +410,20 @@ function BaseUrlSelectStep({
|
||||
description: opt.url,
|
||||
value: opt.url,
|
||||
}));
|
||||
const [cursor, setCursor] = useState(flow.state.baseUrlOptionIndex);
|
||||
const { cursor, cursorRef, setCursor } = useBatchSafeCursor(
|
||||
flow.state.baseUrlOptionIndex,
|
||||
);
|
||||
useKeyboard((key) => {
|
||||
const o = toOriginalKey(key);
|
||||
if (o.name === 'up' || o.name === 'down') {
|
||||
const next =
|
||||
o.name === 'up'
|
||||
? Math.max(0, cursor - 1)
|
||||
: Math.min(items.length - 1, cursor + 1);
|
||||
const next = findNextEnabledIndex(items, cursorRef.current, o.name);
|
||||
setCursor(next);
|
||||
// ink onHighlight parity: remember the highlighted option so a
|
||||
// go-back later restores the cursor.
|
||||
const item = items[next];
|
||||
if (item) flow.highlightBaseUrl(item.value);
|
||||
} else if (o.name === 'return') {
|
||||
const item = items[cursor];
|
||||
const item = items[cursorRef.current];
|
||||
if (item) flow.selectBaseUrl(item.value);
|
||||
}
|
||||
});
|
||||
@@ -517,16 +523,13 @@ function ApiKeyStep({
|
||||
);
|
||||
}
|
||||
|
||||
const MODEL_CUSTOM_INPUT_FOCUS_INDEX = -2;
|
||||
|
||||
function uniqueIds(ids: string[]): string[] {
|
||||
return [...new Set(ids)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Model IDs step. Simplified vs ink: no search box over the recommended
|
||||
* list; custom IDs and recommended multi-select both feed the shared
|
||||
* flow.state.modelIds like the ink ModelIdsStep.
|
||||
* Model IDs step. Custom IDs and the recommended multi-select both feed the
|
||||
* shared flow.state.modelIds, exactly like the ink ModelIdsStep this mirrors.
|
||||
*/
|
||||
function ModelsStep({
|
||||
provider,
|
||||
@@ -537,8 +540,17 @@ function ModelsStep({
|
||||
flow: ProviderSetupFlow;
|
||||
retrySeq: number;
|
||||
}) {
|
||||
const modelOptions = useMemo(
|
||||
() => provider.models?.map((m) => m.id) ?? [],
|
||||
// ink ModelIdsStep parity: rows carry the formatted label (id padded to the
|
||||
// description column plus context/thinking/modality details), the list is a
|
||||
// search-filtered window of MAX_MODELS_TO_SHOW rows, and focus is conveyed by
|
||||
// colour alone — ink draws no cursor glyph here.
|
||||
const modelOptions = useMemo<ModelOption[]>(
|
||||
() =>
|
||||
provider.models?.map((model) => ({
|
||||
key: model.id,
|
||||
value: model.id,
|
||||
label: formatModelOptionLabel(model),
|
||||
})) ?? [],
|
||||
[provider.models],
|
||||
);
|
||||
const hasSelectableModels = modelOptions.length > 0;
|
||||
@@ -546,19 +558,32 @@ function ModelsStep({
|
||||
() => normalizeModelIds(flow.state.modelIds),
|
||||
[flow.state.modelIds],
|
||||
);
|
||||
const recommendedIds = useMemo(() => new Set(modelOptions), [modelOptions]);
|
||||
const [focus, setFocus] = useState(MODEL_CUSTOM_INPUT_FOCUS_INDEX);
|
||||
const recommendedIds = useMemo(
|
||||
() => new Set(modelOptions.map((item) => item.key)),
|
||||
[modelOptions],
|
||||
);
|
||||
// The handler below reads the mirror, not this value: arrows and the Space
|
||||
// that follows them arrive in one stdin read, against the render that
|
||||
// registered the handler.
|
||||
const {
|
||||
cursor: focus,
|
||||
cursorRef: focusRef,
|
||||
setCursor: setFocus,
|
||||
} = useBatchSafeCursor(MODEL_CUSTOM_INPUT_FOCUS_INDEX);
|
||||
const [customText, setCustomText] = useState(() =>
|
||||
selectedModelIds.filter((id) => !recommendedIds.has(id)).join(', '),
|
||||
);
|
||||
const [checked, setChecked] = useState<ReadonlySet<string>>(
|
||||
() => new Set(selectedModelIds.filter((id) => recommendedIds.has(id))),
|
||||
);
|
||||
|
||||
// Keystrokes of one burst are handled against the render that registered the
|
||||
// handler, whose `checked` set is already stale by the second Space. The
|
||||
// mirror is written synchronously so each tick sees the previous one.
|
||||
const checkedRef = useRef<ReadonlySet<string>>(checked);
|
||||
const {
|
||||
value: checked,
|
||||
ref: checkedRef,
|
||||
setValue: setChecked,
|
||||
} = useBatchSafeState<ReadonlySet<string>>(
|
||||
() => new Set(selectedModelIds.filter((id) => recommendedIds.has(id))),
|
||||
);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
|
||||
const syncModelIds = useCallback(
|
||||
(custom: string, keys: ReadonlySet<string>) => {
|
||||
@@ -574,7 +599,7 @@ function ModelsStep({
|
||||
setCustomText(next);
|
||||
syncModelIds(next, checkedRef.current);
|
||||
},
|
||||
[syncModelIds],
|
||||
[syncModelIds, checkedRef],
|
||||
);
|
||||
|
||||
// ink keeps this field in a TextInput whose buffer survives the list taking
|
||||
@@ -583,17 +608,40 @@ function ModelsStep({
|
||||
// flow, so its Enter only fires the install, and a rejected install leaves
|
||||
// this very step mounted with the latch that Enter armed.
|
||||
const custom = useLineEdit(customText, updateCustom, retrySeq);
|
||||
const search = useLineEdit(searchText, setSearchText, retrySeq);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const query = searchText.trim().toLowerCase();
|
||||
if (!query) return modelOptions;
|
||||
return modelOptions.filter((item) =>
|
||||
modelOptionSearchText(item).includes(query),
|
||||
);
|
||||
}, [modelOptions, searchText]);
|
||||
|
||||
const scrollOffset =
|
||||
focus < 0
|
||||
? 0
|
||||
: Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
focus - MAX_MODELS_TO_SHOW + 1,
|
||||
filtered.length - MAX_MODELS_TO_SHOW,
|
||||
),
|
||||
);
|
||||
const visible = filtered.slice(
|
||||
scrollOffset,
|
||||
scrollOffset + MAX_MODELS_TO_SHOW,
|
||||
);
|
||||
|
||||
const toggleRecommended = useCallback(
|
||||
(id: string) => {
|
||||
const next = new Set(checkedRef.current);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
checkedRef.current = next;
|
||||
setChecked(next);
|
||||
syncModelIds(custom.text, next);
|
||||
},
|
||||
[custom, syncModelIds],
|
||||
[custom, syncModelIds, checkedRef, setChecked],
|
||||
);
|
||||
|
||||
const submit = useCallback(() => {
|
||||
@@ -607,29 +655,42 @@ function ModelsStep({
|
||||
) {
|
||||
custom.settle();
|
||||
}
|
||||
}, [custom, flow]);
|
||||
}, [custom, flow, checkedRef]);
|
||||
|
||||
useKeyboard((key) => {
|
||||
if (custom.settled) return;
|
||||
const o = toOriginalKey(key);
|
||||
if (focus >= 0) {
|
||||
const focused = focusRef.current;
|
||||
if (focused >= 0) {
|
||||
if (o.name === 'tab') {
|
||||
setFocus(MODEL_CUSTOM_INPUT_FOCUS_INDEX);
|
||||
} else if (o.name === 'up') {
|
||||
setFocus((f) => (f <= 0 ? MODEL_CUSTOM_INPUT_FOCUS_INDEX : f - 1));
|
||||
setFocus(focused <= 0 ? MODEL_SEARCH_INPUT_FOCUS_INDEX : focused - 1);
|
||||
} else if (o.name === 'down') {
|
||||
setFocus((f) => Math.min(f + 1, modelOptions.length - 1));
|
||||
setFocus(Math.max(0, Math.min(focused + 1, filtered.length - 1)));
|
||||
} else if (o.name === 'space') {
|
||||
const id = modelOptions[focus];
|
||||
if (id) toggleRecommended(id);
|
||||
const item = filtered[focused];
|
||||
if (item) toggleRecommended(item.key);
|
||||
} else if (o.name === 'return') {
|
||||
submit();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (focused === MODEL_SEARCH_INPUT_FOCUS_INDEX) {
|
||||
if (o.name === 'up') {
|
||||
setFocus(MODEL_CUSTOM_INPUT_FOCUS_INDEX);
|
||||
} else if (o.name === 'tab' || o.name === 'down') {
|
||||
if (filtered.length > 0) setFocus(0);
|
||||
} else if (o.name === 'return' || o.name === 'enter') {
|
||||
submit();
|
||||
} else if (!search.handleKey(o) && isPrintableKeyInput(key)) {
|
||||
search.insert(key.sequence);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Custom-ID input focus.
|
||||
if (o.name === 'tab' || o.name === 'down') {
|
||||
if (hasSelectableModels) setFocus(0);
|
||||
if (hasSelectableModels) setFocus(MODEL_SEARCH_INPUT_FOCUS_INDEX);
|
||||
return;
|
||||
}
|
||||
if (o.name === 'return' || o.name === 'enter') {
|
||||
@@ -640,71 +701,130 @@ function ModelsStep({
|
||||
custom.insert(key.sequence);
|
||||
}
|
||||
});
|
||||
// Pastes land in the custom-ID input only when it owns focus; while the
|
||||
// recommended list is focused there is no text field to receive them.
|
||||
// Pastes land in whichever text field owns focus; while the recommended list
|
||||
// is focused there is no text field to receive them.
|
||||
usePaste((event: PasteEvent) => {
|
||||
if (focus >= 0) return;
|
||||
const focused = focusRef.current;
|
||||
const target =
|
||||
focused === MODEL_CUSTOM_INPUT_FOCUS_INDEX
|
||||
? custom
|
||||
: focused === MODEL_SEARCH_INPUT_FOCUS_INDEX
|
||||
? search
|
||||
: null;
|
||||
if (!target) return;
|
||||
const text = normalizePastedText(decodePasteBytes(event.bytes));
|
||||
if (!text) return;
|
||||
event.preventDefault();
|
||||
custom.insert(text);
|
||||
target.insert(text);
|
||||
});
|
||||
|
||||
if (!hasSelectableModels) {
|
||||
const defaultIds = (provider.models ?? [])
|
||||
.map((model) => model.id)
|
||||
.join(', ');
|
||||
return (
|
||||
<box flexDirection="column" marginTop={1}>
|
||||
<box marginTop={1}>
|
||||
<text fg={C.dim}>
|
||||
{defaultIds
|
||||
? t(
|
||||
'Enter model IDs separated by commas. Examples: {{modelIds}}',
|
||||
{
|
||||
modelIds: defaultIds,
|
||||
},
|
||||
)
|
||||
: t('Enter model IDs separated by commas.')}
|
||||
</text>
|
||||
</box>
|
||||
<InputLine
|
||||
value={customText}
|
||||
caret={custom.caret}
|
||||
placeholder={defaultIds || 'model-id-1, model-id-2'}
|
||||
active
|
||||
/>
|
||||
{flow.state.modelIdsError && (
|
||||
<box marginTop={1}>
|
||||
<text fg={C.red}>{flow.state.modelIdsError}</text>
|
||||
</box>
|
||||
)}
|
||||
<box marginTop={1}>
|
||||
<text fg={C.dim}>{NAV_HINT_INPUT}</text>
|
||||
</box>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<box flexDirection="column" marginTop={1}>
|
||||
<text fg={C.text}>
|
||||
{t(
|
||||
'Enter model IDs directly. Use commas to configure multiple models.',
|
||||
)}
|
||||
</text>
|
||||
<box marginTop={1}>
|
||||
<text fg={C.dim}>
|
||||
{t(
|
||||
'Enter model IDs directly. Use commas to configure multiple models.',
|
||||
)}
|
||||
</text>
|
||||
</box>
|
||||
<InputLine
|
||||
value={customText}
|
||||
caret={custom.caret}
|
||||
placeholder="model-id"
|
||||
active={focus < 0}
|
||||
active={focus === MODEL_CUSTOM_INPUT_FOCUS_INDEX}
|
||||
/>
|
||||
<box>
|
||||
<text fg={C.dim}>
|
||||
{t(
|
||||
'Checked recommended models are applied on submit but not copied into the input.',
|
||||
)}
|
||||
</text>
|
||||
</box>
|
||||
<box marginTop={1}>
|
||||
<text fg={C.dim}>{t('Recommended models')}</text>
|
||||
</box>
|
||||
<box flexDirection="column">
|
||||
<text fg={C.dim}>{t('Search')}</text>
|
||||
<InputLine
|
||||
value={searchText}
|
||||
caret={search.caret}
|
||||
placeholder="search"
|
||||
active={focus === MODEL_SEARCH_INPUT_FOCUS_INDEX}
|
||||
marginTop={0}
|
||||
/>
|
||||
</box>
|
||||
<box flexDirection="column" marginTop={1}>
|
||||
{visible.length > 0 ? (
|
||||
visible.map((item, visibleIndex) => {
|
||||
const modelIndex = scrollOffset + visibleIndex;
|
||||
const isFocused = focus === modelIndex;
|
||||
const isSelected = checked.has(item.key);
|
||||
const color = isFocused ? C.green : isSelected ? C.accent : C.text;
|
||||
return (
|
||||
<box key={item.key} flexDirection="row" alignItems="flex-start">
|
||||
<box minWidth={4} flexShrink={0}>
|
||||
<text fg={color}>
|
||||
{isSelected ? ICON.RADIO_FILLED : ICON.CIRCLE_EMPTY}
|
||||
</text>
|
||||
</box>
|
||||
<box flexGrow={1}>
|
||||
<text fg={color}>{item.label}</text>
|
||||
</box>
|
||||
</box>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<text fg={C.dim}>{t('No recommended models match.')}</text>
|
||||
)}
|
||||
</box>
|
||||
{flow.state.modelIdsError && (
|
||||
<box marginTop={1}>
|
||||
<text fg={C.red}>{flow.state.modelIdsError}</text>
|
||||
</box>
|
||||
)}
|
||||
{hasSelectableModels ? (
|
||||
<>
|
||||
<box marginTop={1}>
|
||||
<text fg={C.dim}>{t('Recommended models')}</text>
|
||||
</box>
|
||||
<box flexDirection="column" marginTop={1}>
|
||||
{modelOptions.map((id, i) => {
|
||||
const isChecked = checked.has(id);
|
||||
const focused = i === focus;
|
||||
return (
|
||||
<box key={id} flexDirection="row">
|
||||
<text fg={focused ? C.accent : C.dim}>
|
||||
{focused ? '› ' : ' '}
|
||||
</text>
|
||||
<text fg={focused ? C.accent : C.dim}>
|
||||
{isChecked
|
||||
? `${ICON.RADIO_FILLED} `
|
||||
: `${ICON.CIRCLE_EMPTY} `}
|
||||
</text>
|
||||
<text fg={focused ? C.text : C.dim}>{id}</text>
|
||||
</box>
|
||||
);
|
||||
})}
|
||||
</box>
|
||||
<box marginTop={1}>
|
||||
<text fg={C.dim}>
|
||||
{t(
|
||||
'Tab toggles input/list, Space toggles a model, Enter to continue, Esc to go back',
|
||||
)}
|
||||
</text>
|
||||
</box>
|
||||
</>
|
||||
) : (
|
||||
<box marginTop={1}>
|
||||
<text fg={C.dim}>{NAV_HINT_INPUT}</text>
|
||||
</box>
|
||||
)}
|
||||
<box marginTop={1}>
|
||||
<text fg={C.dim}>
|
||||
{t(
|
||||
'Enter to submit, ↑↓/Tab to switch input, search, and recommendations, Space to toggle recommendations, Esc to go back',
|
||||
)}
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
@@ -1164,34 +1284,40 @@ function AuthDialogFlow({
|
||||
|
||||
const mainCursor = mainIndex ?? defaultMainIndex;
|
||||
const subCursor = activeSubMenu ? (subMenuIndex[viewLevel] ?? 0) : 0;
|
||||
// A burst of keys reaches the handler registered by the last render, so the
|
||||
// cursors it reads must be written synchronously by the movers below.
|
||||
const mainCursorRef = useRef(mainCursor);
|
||||
mainCursorRef.current = mainCursor;
|
||||
const subCursorRef = useRef(subCursor);
|
||||
subCursorRef.current = subCursor;
|
||||
const moveMain = (index: number) => {
|
||||
mainCursorRef.current = index;
|
||||
setMainIndex(index);
|
||||
};
|
||||
const moveSub = (index: number) => {
|
||||
subCursorRef.current = index;
|
||||
setSubMenuIndex((prev) => ({ ...prev, [viewLevel]: index }));
|
||||
};
|
||||
|
||||
useKeyboard((key) => {
|
||||
const o = toOriginalKey(key);
|
||||
if (viewLevel === 'main') {
|
||||
if (o.name === 'up') {
|
||||
setMainIndex(Math.max(0, mainCursor - 1));
|
||||
} else if (o.name === 'down') {
|
||||
setMainIndex(Math.min(MAIN_ITEMS.length - 1, mainCursor + 1));
|
||||
if (o.name === 'up' || o.name === 'down') {
|
||||
moveMain(
|
||||
findNextEnabledIndex(MAIN_ITEMS, mainCursorRef.current, o.name),
|
||||
);
|
||||
} else if (o.name === 'return') {
|
||||
const item = MAIN_ITEMS[mainCursor];
|
||||
const item = MAIN_ITEMS[mainCursorRef.current];
|
||||
if (item) handleMainSelect(item.value as MainOption);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (activeSubMenu) {
|
||||
const items = activeSubMenu;
|
||||
if (o.name === 'up') {
|
||||
setSubMenuIndex((prev) => ({
|
||||
...prev,
|
||||
[viewLevel]: Math.max(0, subCursor - 1),
|
||||
}));
|
||||
} else if (o.name === 'down') {
|
||||
setSubMenuIndex((prev) => ({
|
||||
...prev,
|
||||
[viewLevel]: Math.min(items.length - 1, subCursor + 1),
|
||||
}));
|
||||
if (o.name === 'up' || o.name === 'down') {
|
||||
moveSub(findNextEnabledIndex(items, subCursorRef.current, o.name));
|
||||
} else if (o.name === 'return') {
|
||||
const item = items[subCursor];
|
||||
const item = items[subCursorRef.current];
|
||||
if (item) handleProviderSelect(item.value);
|
||||
}
|
||||
}
|
||||
@@ -1262,12 +1388,12 @@ function AuthDialogFlow({
|
||||
// -- Render -------------------------------------------------------------------
|
||||
|
||||
return (
|
||||
<Shell title={viewTitle} onClose={onClose}>
|
||||
<Shell title={viewTitle} onClose={onClose} borderStyle="single">
|
||||
{viewLevel === 'main' && (
|
||||
<>
|
||||
<RadioList items={MAIN_ITEMS} cursor={mainCursor} />
|
||||
<box marginTop={2}>
|
||||
<text fg={C.dim}>{'─'.repeat(60)}</text>
|
||||
<box marginTop={1}>
|
||||
<text fg={C.borderDefault}>{'─'.repeat(80)}</text>
|
||||
</box>
|
||||
<box marginTop={1}>
|
||||
<text
|
||||
@@ -1275,7 +1401,7 @@ function AuthDialogFlow({
|
||||
>{`${t('Terms of Services and Privacy Notice')}:`}</text>
|
||||
</box>
|
||||
<box>
|
||||
<text fg={C.purple}>
|
||||
<text fg={C.dim} attributes={8}>
|
||||
{
|
||||
'https://qwenlm.github.io/qwen-code-docs/en/users/support/tos-privacy/'
|
||||
}
|
||||
|
||||
@@ -544,7 +544,9 @@ function OutcomeSelect(props: {
|
||||
onHover={select.highlightIndex}
|
||||
onWheel={(direction) =>
|
||||
select.setActiveIndex(
|
||||
direction === 'up' ? select.activeIndex - 1 : select.activeIndex + 1,
|
||||
direction === 'up'
|
||||
? select.activeIndexRef.current - 1
|
||||
: select.activeIndexRef.current + 1,
|
||||
)
|
||||
}
|
||||
onSelectIndex={select.selectIndex}
|
||||
|
||||
@@ -29,8 +29,11 @@ export interface DialogListItem<T = string> {
|
||||
* step at a time, wrapping around, until a non-disabled row is found. When
|
||||
* every row is disabled (or the list is empty) the current index is kept.
|
||||
*/
|
||||
export function findNextEnabledIndex<T>(
|
||||
items: ReadonlyArray<DialogListItem<T>>,
|
||||
export function findNextEnabledIndex(
|
||||
// Row shapes differ per caller and most never declare the flag; a
|
||||
// `disabled?: boolean` constraint would trip weak-type detection on all of
|
||||
// them, so the flag is read through a cast instead.
|
||||
items: readonly unknown[],
|
||||
from: number,
|
||||
direction: 'up' | 'down',
|
||||
): number {
|
||||
@@ -41,7 +44,8 @@ export function findNextEnabledIndex<T>(
|
||||
let nextIndex = from;
|
||||
for (let i = 0; i < len; i++) {
|
||||
nextIndex = (nextIndex + step + len) % len;
|
||||
if (!items[nextIndex]?.disabled) {
|
||||
const row = items[nextIndex] as { disabled?: boolean } | undefined;
|
||||
if (!row?.disabled) {
|
||||
return nextIndex;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -394,7 +394,7 @@ export function OpenTuiExtensionsDialog(props: OpenTuiExtensionsDialogProps) {
|
||||
} else if (name === 'escape') {
|
||||
onClose();
|
||||
} else if (activeTab === EXTENSIONS_TABS.INSTALLED && !busy) {
|
||||
const row = listItems[listSelect.activeIndex]?.row;
|
||||
const row = listItems[listSelect.activeIndexRef.current]?.row;
|
||||
if (!row) return;
|
||||
if (name === 'space' || original.sequence === ' ') {
|
||||
onRowAction?.(row, 'toggle');
|
||||
@@ -490,7 +490,7 @@ export function OpenTuiExtensionsDialog(props: OpenTuiExtensionsDialogProps) {
|
||||
0,
|
||||
Math.min(
|
||||
detailActions.length - 1,
|
||||
detailSelect.activeIndex +
|
||||
detailSelect.activeIndexRef.current +
|
||||
(direction === 'down' ? 1 : -1),
|
||||
),
|
||||
),
|
||||
@@ -525,7 +525,8 @@ export function OpenTuiExtensionsDialog(props: OpenTuiExtensionsDialogProps) {
|
||||
0,
|
||||
Math.min(
|
||||
scopeItems.length - 1,
|
||||
scopeSelect.activeIndex + (direction === 'down' ? 1 : -1),
|
||||
scopeSelect.activeIndexRef.current +
|
||||
(direction === 'down' ? 1 : -1),
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -574,7 +575,8 @@ export function OpenTuiExtensionsDialog(props: OpenTuiExtensionsDialogProps) {
|
||||
0,
|
||||
Math.min(
|
||||
listItems.length - 1,
|
||||
listSelect.activeIndex + (direction === 'down' ? 1 : -1),
|
||||
listSelect.activeIndexRef.current +
|
||||
(direction === 'down' ? 1 : -1),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -367,7 +367,10 @@ export function OpenTuiHooksDialog({
|
||||
|
||||
const wheel =
|
||||
(
|
||||
select: { activeIndex: number; highlightIndex: (index: number) => void },
|
||||
select: {
|
||||
activeIndexRef: Readonly<{ current: number }>;
|
||||
highlightIndex: (index: number) => void;
|
||||
},
|
||||
length: number,
|
||||
) =>
|
||||
(direction: 'up' | 'down') =>
|
||||
@@ -376,7 +379,7 @@ export function OpenTuiHooksDialog({
|
||||
0,
|
||||
Math.min(
|
||||
length - 1,
|
||||
select.activeIndex + (direction === 'down' ? 1 : -1),
|
||||
select.activeIndexRef.current + (direction === 'down' ? 1 : -1),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -22,8 +22,10 @@ import { t } from '../../i18n/index.js';
|
||||
import { MCPServerStatus } from '@qwen-code/qwen-code-core/tools/mcp-status.js';
|
||||
import { ICON } from '../constants.js';
|
||||
import { toOriginalKey } from './key-map.js';
|
||||
import { useBatchSafeCursor } from './batch-cursor.js';
|
||||
import { keyMatchers, Command } from '../keyMatchers.js';
|
||||
import { DialogFrame, FooterHint } from './dialogs-shared.js';
|
||||
import { findNextEnabledIndex } from './dialogs-core.js';
|
||||
|
||||
export const MCP_MANAGEMENT_STEPS = {
|
||||
SERVER_LIST: 'server-list',
|
||||
@@ -296,7 +298,10 @@ export function mcpStepFooter(
|
||||
}
|
||||
}
|
||||
|
||||
/** Clamp-style navigation — MCP lists do NOT wrap (unlike the radio lists). */
|
||||
/**
|
||||
* Clamp-style navigation — ink's server/tool/resource steps clamp here; the
|
||||
* server-detail action list is a radio list and wraps instead.
|
||||
*/
|
||||
export function clampNavIndex(
|
||||
current: number,
|
||||
count: number,
|
||||
@@ -340,10 +345,26 @@ export function OpenTuiMcpDialog(props: OpenTuiMcpDialogProps) {
|
||||
const [selectedTool, setSelectedTool] = useState<McpToolInfo | null>(null);
|
||||
const [selectedResource, setSelectedResource] =
|
||||
useState<McpResourceInfo | null>(null);
|
||||
const [serverCursor, setServerCursor] = useState(0);
|
||||
const [actionCursor, setActionCursor] = useState(0);
|
||||
const [toolCursor, setToolCursor] = useState(0);
|
||||
const [resourceCursor, setResourceCursor] = useState(0);
|
||||
const {
|
||||
cursor: serverCursor,
|
||||
cursorRef: serverCursorRef,
|
||||
setCursor: setServerCursor,
|
||||
} = useBatchSafeCursor();
|
||||
const {
|
||||
cursor: actionCursor,
|
||||
cursorRef: actionCursorRef,
|
||||
setCursor: setActionCursor,
|
||||
} = useBatchSafeCursor();
|
||||
const {
|
||||
cursor: toolCursor,
|
||||
cursorRef: toolCursorRef,
|
||||
setCursor: setToolCursor,
|
||||
} = useBatchSafeCursor();
|
||||
const {
|
||||
cursor: resourceCursor,
|
||||
cursorRef: resourceCursorRef,
|
||||
setCursor: setResourceCursor,
|
||||
} = useBatchSafeCursor();
|
||||
|
||||
const currentStep = (navigationStack[navigationStack.length - 1] ??
|
||||
MCP_MANAGEMENT_STEPS.SERVER_LIST) as McpManagementStep;
|
||||
@@ -381,15 +402,15 @@ export function OpenTuiMcpDialog(props: OpenTuiMcpDialogProps) {
|
||||
return;
|
||||
}
|
||||
if (keyMatchers[Command.SELECTION_UP](original)) {
|
||||
setServerCursor((prev) =>
|
||||
clampNavIndex(prev, flatServers.length, 'up'),
|
||||
setServerCursor(
|
||||
clampNavIndex(serverCursorRef.current, flatServers.length, 'up'),
|
||||
);
|
||||
} else if (keyMatchers[Command.SELECTION_DOWN](original)) {
|
||||
setServerCursor((prev) =>
|
||||
clampNavIndex(prev, flatServers.length, 'down'),
|
||||
setServerCursor(
|
||||
clampNavIndex(serverCursorRef.current, flatServers.length, 'down'),
|
||||
);
|
||||
} else if (name === 'return') {
|
||||
const server = flatServers[serverCursor];
|
||||
const server = flatServers[serverCursorRef.current];
|
||||
if (server) {
|
||||
setSelectedServerName(server.name);
|
||||
setActionCursor(0);
|
||||
@@ -406,15 +427,15 @@ export function OpenTuiMcpDialog(props: OpenTuiMcpDialogProps) {
|
||||
|
||||
if (currentStep === MCP_MANAGEMENT_STEPS.SERVER_DETAIL) {
|
||||
if (keyMatchers[Command.SELECTION_UP](original)) {
|
||||
setActionCursor((prev) =>
|
||||
clampNavIndex(prev, detailActions.length, 'up'),
|
||||
setActionCursor(
|
||||
findNextEnabledIndex(detailActions, actionCursorRef.current, 'up'),
|
||||
);
|
||||
} else if (keyMatchers[Command.SELECTION_DOWN](original)) {
|
||||
setActionCursor((prev) =>
|
||||
clampNavIndex(prev, detailActions.length, 'down'),
|
||||
setActionCursor(
|
||||
findNextEnabledIndex(detailActions, actionCursorRef.current, 'down'),
|
||||
);
|
||||
} else if (name === 'return') {
|
||||
const action = detailActions[actionCursor];
|
||||
const action = detailActions[actionCursorRef.current];
|
||||
if (!action || !selectedServer) return;
|
||||
switch (action.action) {
|
||||
case 'view-tools':
|
||||
@@ -434,13 +455,15 @@ export function OpenTuiMcpDialog(props: OpenTuiMcpDialogProps) {
|
||||
|
||||
if (currentStep === MCP_MANAGEMENT_STEPS.TOOL_LIST) {
|
||||
if (keyMatchers[Command.SELECTION_UP](original)) {
|
||||
setToolCursor((prev) => clampNavIndex(prev, serverTools.length, 'up'));
|
||||
setToolCursor(
|
||||
clampNavIndex(toolCursorRef.current, serverTools.length, 'up'),
|
||||
);
|
||||
} else if (keyMatchers[Command.SELECTION_DOWN](original)) {
|
||||
setToolCursor((prev) =>
|
||||
clampNavIndex(prev, serverTools.length, 'down'),
|
||||
setToolCursor(
|
||||
clampNavIndex(toolCursorRef.current, serverTools.length, 'down'),
|
||||
);
|
||||
} else if (name === 'return') {
|
||||
const tool = serverTools[toolCursor];
|
||||
const tool = serverTools[toolCursorRef.current];
|
||||
if (tool) {
|
||||
setSelectedTool(tool);
|
||||
navigateToStep(MCP_MANAGEMENT_STEPS.TOOL_DETAIL);
|
||||
@@ -451,15 +474,23 @@ export function OpenTuiMcpDialog(props: OpenTuiMcpDialogProps) {
|
||||
|
||||
if (currentStep === MCP_MANAGEMENT_STEPS.RESOURCE_LIST) {
|
||||
if (keyMatchers[Command.SELECTION_UP](original)) {
|
||||
setResourceCursor((prev) =>
|
||||
clampNavIndex(prev, serverResources.length, 'up'),
|
||||
setResourceCursor(
|
||||
clampNavIndex(
|
||||
resourceCursorRef.current,
|
||||
serverResources.length,
|
||||
'up',
|
||||
),
|
||||
);
|
||||
} else if (keyMatchers[Command.SELECTION_DOWN](original)) {
|
||||
setResourceCursor((prev) =>
|
||||
clampNavIndex(prev, serverResources.length, 'down'),
|
||||
setResourceCursor(
|
||||
clampNavIndex(
|
||||
resourceCursorRef.current,
|
||||
serverResources.length,
|
||||
'down',
|
||||
),
|
||||
);
|
||||
} else if (name === 'return') {
|
||||
const resource = serverResources[resourceCursor];
|
||||
const resource = serverResources[resourceCursorRef.current];
|
||||
if (resource) {
|
||||
setSelectedResource(resource);
|
||||
navigateToStep(MCP_MANAGEMENT_STEPS.RESOURCE_DETAIL);
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
/** @jsxImportSource @opentui/react */
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Editor dialog geometry (ink EditorSettingsDialog parity): the shared list
|
||||
* primitive's number column, the absent chrome title row, and the bottom hint
|
||||
* clipped to the left column's own width the way ink's `wrap="truncate"` clips
|
||||
* it. The dialog used to hand-roll its rows, drop the numbers, print a title
|
||||
* ink does not have, and wrap the hint onto a second row.
|
||||
*/
|
||||
|
||||
import { act, render, screen } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { SettingScope, type LoadedSettings } from '../../config/settings.js';
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const state = {
|
||||
keyboardHandlers: [] as Array<(key: unknown) => void>,
|
||||
width: 100,
|
||||
editors: [
|
||||
{ name: 'None', type: 'not_set', disabled: false },
|
||||
{ name: 'Cursor (Not installed)', type: 'cursor', disabled: true },
|
||||
{ name: 'Vim', type: 'vim', disabled: false },
|
||||
],
|
||||
};
|
||||
async function buildJsxRuntime() {
|
||||
const React = await import('react');
|
||||
const jsx = (
|
||||
type: unknown,
|
||||
props: { children?: unknown; key?: React.Key } | null,
|
||||
key?: React.Key,
|
||||
) => {
|
||||
const config = key === undefined ? props : { ...props, key };
|
||||
const children = (config?.children ?? null) as React.ReactNode;
|
||||
if (type === 'box' || type === 'text') {
|
||||
return React.createElement(
|
||||
type === 'box' ? 'div' : 'span',
|
||||
key === undefined ? null : { key },
|
||||
children,
|
||||
);
|
||||
}
|
||||
return React.createElement(
|
||||
type as React.ElementType,
|
||||
config as Record<string, unknown>,
|
||||
children,
|
||||
);
|
||||
};
|
||||
return { jsx, jsxs: jsx, jsxDEV: jsx, Fragment: React.Fragment };
|
||||
}
|
||||
return { state, buildJsxRuntime };
|
||||
});
|
||||
|
||||
vi.mock('@opentui/react', async () => {
|
||||
const React = await import('react');
|
||||
return {
|
||||
useKeyboard: (handler: (key: unknown) => void) => {
|
||||
const latest = React.useRef(handler);
|
||||
latest.current = handler;
|
||||
const stable = React.useRef<((key: unknown) => void) | undefined>(
|
||||
undefined,
|
||||
);
|
||||
if (!stable.current) {
|
||||
stable.current = (key: unknown) => latest.current(key);
|
||||
mocks.state.keyboardHandlers.push(stable.current);
|
||||
}
|
||||
},
|
||||
useTerminalDimensions: () => ({
|
||||
width: mocks.state.width,
|
||||
height: 40,
|
||||
}),
|
||||
useRenderer: () => ({
|
||||
addInputHandler: () => undefined,
|
||||
removeInputHandler: () => undefined,
|
||||
}),
|
||||
};
|
||||
});
|
||||
vi.mock('@opentui/core', () => ({
|
||||
SyntaxStyle: { fromStyles: () => ({}) },
|
||||
MouseButton: { LEFT: 0 },
|
||||
}));
|
||||
// The select handler re-checks the pick against the machine before persisting;
|
||||
// nothing is detectable in the test runtime, so let every editor through.
|
||||
vi.mock('@qwen-code/qwen-code-core/utils/editor.js', () => ({
|
||||
allowEditorTypeInSandbox: () => true,
|
||||
checkHasEditorType: () => true,
|
||||
isEditorAvailable: () => true,
|
||||
}));
|
||||
vi.mock('@opentui/react/jsx-runtime', () => mocks.buildJsxRuntime());
|
||||
vi.mock('@opentui/react/jsx-dev-runtime', () => mocks.buildJsxRuntime());
|
||||
vi.mock('./key-map.js', () => ({
|
||||
toOriginalKey: (key: { name?: string; shift?: boolean }) => ({
|
||||
name: key.name ?? '',
|
||||
shift: key.shift ?? false,
|
||||
}),
|
||||
}));
|
||||
// The real manager probes the machine for installed editors at module scope,
|
||||
// which would make the row set depend on the developer's own shell.
|
||||
vi.mock('../editors/editorSettingsManager.js', () => ({
|
||||
EDITOR_DISPLAY_NAMES: { vim: 'Vim', cursor: 'Cursor' },
|
||||
editorSettingsManager: {
|
||||
getAvailableEditorDisplays: () => mocks.state.editors,
|
||||
},
|
||||
}));
|
||||
|
||||
import { OpenTuiEditorDialog } from './dialogs-misc.js';
|
||||
|
||||
function press(name: string, shift = false) {
|
||||
if (mocks.state.keyboardHandlers.length === 0) {
|
||||
throw new Error('no keyboard handler registered');
|
||||
}
|
||||
act(() => {
|
||||
for (const handler of [...mocks.state.keyboardHandlers]) {
|
||||
handler({ name, shift });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** A list row's text: marker, then the number column, then the label. */
|
||||
function rowText(labelPrefix: string): string {
|
||||
// The right pane also renders the resolved editor name, so only accept the
|
||||
// span that sits in a marker + number + label row.
|
||||
const rows = screen
|
||||
.getAllByText((content) => content.startsWith(labelPrefix))
|
||||
.map((el) => (el.parentElement?.parentElement?.textContent ?? '').trim())
|
||||
.filter((row) => /^›?\d+\./.test(row));
|
||||
if (rows.length !== 1) {
|
||||
throw new Error(
|
||||
`expected one row for "${labelPrefix}", got ${rows.length}`,
|
||||
);
|
||||
}
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
function renderDialog(options: { setValue?: ReturnType<typeof vi.fn> } = {}) {
|
||||
const setValue = options.setValue ?? vi.fn();
|
||||
const onClose = vi.fn();
|
||||
const notify = vi.fn();
|
||||
const settings = {
|
||||
isTrusted: true,
|
||||
merged: { general: {} },
|
||||
forScope: () => ({ settings: { general: {} } }),
|
||||
setValue,
|
||||
} as unknown as LoadedSettings;
|
||||
render(
|
||||
<OpenTuiEditorDialog
|
||||
settings={settings}
|
||||
onClose={onClose}
|
||||
notify={notify}
|
||||
/>,
|
||||
);
|
||||
return { setValue, onClose, notify };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.state.keyboardHandlers.length = 0;
|
||||
mocks.state.width = 100;
|
||||
});
|
||||
|
||||
describe('OpenTuiEditorDialog', () => {
|
||||
it('numbers the editor rows and marks the cursor, as ink radio rows do', () => {
|
||||
renderDialog();
|
||||
expect(rowText('None')).toBe('›1.None');
|
||||
expect(rowText('Cursor (Not installed)')).toBe('2.Cursor (Not installed)');
|
||||
expect(rowText('Vim')).toBe('3.Vim');
|
||||
});
|
||||
|
||||
it('draws no chrome title row of its own', () => {
|
||||
renderDialog();
|
||||
expect(screen.queryByText('Editor')).toBeNull();
|
||||
});
|
||||
|
||||
it('clips the hint to the left column with a single ellipsis', () => {
|
||||
renderDialog();
|
||||
// 100 columns -> a 92-column content box -> a 41-column left column, two
|
||||
// of which are its own padding: the same 39 ink measured on a real frame.
|
||||
const hint = screen.getByText(/^\(Use Enter to select/)
|
||||
.textContent as string;
|
||||
expect(hint).toBe('(Use Enter to select, Tab to configure…');
|
||||
expect(hint).toHaveLength(39);
|
||||
});
|
||||
|
||||
it('numbers the scope rows too, under the Apply To heading', () => {
|
||||
renderDialog();
|
||||
press('tab');
|
||||
expect(screen.getByText('> Apply To')).toBeTruthy();
|
||||
expect(rowText('User Settings')).toBe('›1.User Settings');
|
||||
expect(rowText('Workspace Settings')).toBe('2.Workspace Settings');
|
||||
const hint = screen.getByText(/^\(Use Enter to apply scope/)
|
||||
.textContent as string;
|
||||
expect(hint).not.toContain('back)');
|
||||
expect(hint.endsWith('…')).toBe(true);
|
||||
expect(hint).toHaveLength(39);
|
||||
});
|
||||
|
||||
it('persists the highlighted editor into the chosen scope', () => {
|
||||
const { setValue, onClose } = renderDialog();
|
||||
press('down');
|
||||
press('return');
|
||||
expect(setValue).toHaveBeenCalledWith(
|
||||
SettingScope.User,
|
||||
'general.preferredEditor',
|
||||
'vim',
|
||||
);
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('steps over the disabled row instead of landing on it', () => {
|
||||
renderDialog();
|
||||
press('down');
|
||||
expect(rowText('Vim')).toBe('›3.Vim');
|
||||
expect(rowText('Cursor (Not installed)')).toBe('2.Cursor (Not installed)');
|
||||
});
|
||||
|
||||
it('reports a failed write and keeps the dialog open', () => {
|
||||
const setValue = vi.fn(() => {
|
||||
throw new Error('read-only file system');
|
||||
});
|
||||
const { onClose, notify } = renderDialog({ setValue });
|
||||
press('down');
|
||||
press('return');
|
||||
expect(
|
||||
screen.getByText(/Failed to set editor preference/).textContent,
|
||||
).toBe('Failed to set editor preference: Error: read-only file system');
|
||||
expect(notify).not.toHaveBeenCalled();
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -19,9 +19,23 @@ vi.mock('@opentui/core', () => ({
|
||||
MouseButton: { LEFT: 0 },
|
||||
}));
|
||||
|
||||
import { nextEnabledIndex, readHooksEnabled } from './dialogs-misc.js';
|
||||
import { readHooksEnabled, Shell } from './dialogs-misc.js';
|
||||
import { C } from './theme.js';
|
||||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
|
||||
describe('Shell (ink dialog chrome)', () => {
|
||||
it('frames with a rounded border.default outline and a bold primary title', () => {
|
||||
const frame = Shell({ title: 'Resume Session' }) as unknown as {
|
||||
props: Record<string, unknown> & { children: unknown[] };
|
||||
};
|
||||
expect(frame.props['borderStyle']).toBe('rounded');
|
||||
expect(frame.props['borderColor']).toBe(C.borderDefault);
|
||||
const title = frame.props.children[0] as { props: Record<string, unknown> };
|
||||
expect(title.props['fg']).toBe(C.text);
|
||||
expect(title.props['attributes']).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
const settingsWith = (merged: Record<string, unknown>): LoadedSettings =>
|
||||
({ merged }) as unknown as LoadedSettings;
|
||||
|
||||
@@ -51,27 +65,3 @@ describe('readHooksEnabled (the real disableAllHooks switch)', () => {
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('nextEnabledIndex (ink BaseSelectionList parity)', () => {
|
||||
const items = [
|
||||
{ disabled: false },
|
||||
{ disabled: true },
|
||||
{ disabled: false },
|
||||
{ disabled: false },
|
||||
];
|
||||
|
||||
it('moves to the next enabled entry, skipping disabled ones', () => {
|
||||
expect(nextEnabledIndex(items, 0, 1)).toBe(2);
|
||||
expect(nextEnabledIndex(items, 2, -1)).toBe(0);
|
||||
});
|
||||
|
||||
it('clamps at the edges', () => {
|
||||
expect(nextEnabledIndex(items, 0, -1)).toBe(0);
|
||||
expect(nextEnabledIndex(items, 3, 1)).toBe(3);
|
||||
});
|
||||
|
||||
it('stays put when only disabled entries remain in that direction', () => {
|
||||
const tail = [{ disabled: false }, { disabled: true }, { disabled: true }];
|
||||
expect(nextEnabledIndex(tail, 0, 1)).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import { useRenderer, useKeyboard } from '@opentui/react';
|
||||
import { useRenderer, useTerminalDimensions } from '@opentui/react';
|
||||
import type { Config } from '@qwen-code/qwen-code-core/config/config.js';
|
||||
import type { SessionListItem } from '@qwen-code/qwen-code-core/services/sessionService.js';
|
||||
import type { EditorType } from '@qwen-code/qwen-code-core/utils/editor.js';
|
||||
@@ -37,9 +37,20 @@ import {
|
||||
editorSettingsManager,
|
||||
} from '../editors/editorSettingsManager.js';
|
||||
import { getScopeItems } from '../../config/dialogScopeUtils.js';
|
||||
import { toOriginalKey } from './key-map.js';
|
||||
import { fireSessionDeleteHook } from '../../hooks/session-delete-hook.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import type { DialogListItem } from './dialogs-core.js';
|
||||
import {
|
||||
DialogFrame,
|
||||
DialogSelect,
|
||||
dialogContentWidth,
|
||||
useDialogFrameKeys,
|
||||
useDialogSelect,
|
||||
} from './dialogs-shared.js';
|
||||
import { truncateToWidth } from '../utils/textUtils.js';
|
||||
import { C } from './theme.js';
|
||||
import { useGitBranchName } from '../hooks/useGitBranchName.js';
|
||||
import { useDeleteCommand } from '../hooks/useDeleteCommand.js';
|
||||
import { OpenTuiSessionPicker } from './session-picker.js';
|
||||
|
||||
function useEsc(onClose: () => void) {
|
||||
const renderer = useRenderer();
|
||||
@@ -57,29 +68,29 @@ function useEsc(onClose: () => void) {
|
||||
export function Shell({
|
||||
title,
|
||||
children,
|
||||
borderStyle = 'rounded',
|
||||
}: {
|
||||
title: string;
|
||||
onClose?: () => void;
|
||||
children?: ReactNode;
|
||||
/** ink's AuthDialog frames itself with `borderStyle="single"`; the rest round. */
|
||||
borderStyle?: 'rounded' | 'single';
|
||||
}) {
|
||||
return (
|
||||
<box
|
||||
flexDirection="column"
|
||||
border
|
||||
borderColor={C.dim}
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
borderStyle={borderStyle}
|
||||
borderColor={C.borderDefault}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
marginTop={1}
|
||||
flexShrink={0}
|
||||
>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text fg={C.accent} attributes={1}>
|
||||
{title}
|
||||
</text>
|
||||
<text fg={C.dim}>{'esc to close'}</text>
|
||||
</box>
|
||||
<text fg={C.text} attributes={1}>
|
||||
{title}
|
||||
</text>
|
||||
{children}
|
||||
</box>
|
||||
);
|
||||
@@ -101,32 +112,13 @@ type P = {
|
||||
settings: LoadedSettings;
|
||||
onClose: () => void;
|
||||
/** Delete/Resume report their outcome as a command-style message. */
|
||||
notify?: (text: string) => void;
|
||||
notify?: (text: string, level?: 'info' | 'error') => void;
|
||||
/** Resume: sessions pre-filtered by the command (multiple title matches). */
|
||||
matchedSessions?: SessionListItem[];
|
||||
/** Resume: selection runs the real session switch (host.handleResume). */
|
||||
onSelect?: (sessionId: string) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* ink BaseSelectionList navigation parity: arrow keys clamp at the edges and
|
||||
* skip disabled entries (keeps walking in the same direction; stays put when
|
||||
* no enabled entry remains in that direction).
|
||||
*/
|
||||
export function nextEnabledIndex(
|
||||
items: ReadonlyArray<{ disabled?: boolean }>,
|
||||
current: number,
|
||||
delta: 1 | -1,
|
||||
): number {
|
||||
let next = current;
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
next = Math.min(items.length - 1, Math.max(0, next + delta));
|
||||
if (!items[next]?.disabled) return next;
|
||||
if (next === 0 || next === items.length - 1) break;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
/**
|
||||
* ink EditorSettingsDialog parity: two-pane dialog — left a radio list of
|
||||
* available editors (unavailable ones disabled, like RadioButtonSelect over
|
||||
@@ -135,78 +127,92 @@ export function nextEnabledIndex(
|
||||
* settings.setValue (useEditorSettings.handleEditorSelect guard included).
|
||||
*/
|
||||
export function OpenTuiEditorDialog({ settings, onClose, notify }: P) {
|
||||
useEsc(onClose);
|
||||
const { width } = useTerminalDimensions();
|
||||
const editors = useMemo(
|
||||
() => editorSettingsManager.getAvailableEditorDisplays(),
|
||||
[],
|
||||
);
|
||||
const [mode, setMode] = useState<'editor' | 'scope'>('editor');
|
||||
const [scope, setScope] = useState<SettingScope>(SettingScope.User);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const scopeIndexOf = (s: SettingScope) => {
|
||||
const pref = settings.forScope(s).settings.general?.preferredEditor;
|
||||
const idx = pref ? editors.findIndex((e) => e.type === pref) : 0;
|
||||
return idx >= 0 ? idx : 0;
|
||||
};
|
||||
const [sel, setSel] = useState(() => scopeIndexOf(SettingScope.User));
|
||||
const scopeItems = useMemo(() => getScopeItems(), []);
|
||||
const [scopeSel, setScopeSel] = useState(0);
|
||||
|
||||
// ink: highlighting a scope previews that scope's current preference.
|
||||
const highlightScope = (idx: number) => {
|
||||
const item = scopeItems[idx];
|
||||
if (!item) return;
|
||||
setScope(item.value);
|
||||
setSel(scopeIndexOf(item.value));
|
||||
};
|
||||
const applyScope = () => {
|
||||
const item = scopeItems[scopeSel];
|
||||
if (!item) return;
|
||||
setScope(item.value);
|
||||
setSel(scopeIndexOf(item.value));
|
||||
setMode('editor');
|
||||
};
|
||||
|
||||
const moveEditor = (d: 1 | -1) =>
|
||||
setSel((s) => nextEnabledIndex(editors, s, d));
|
||||
|
||||
const pick = () => {
|
||||
const item = editors[sel];
|
||||
if (!item || item.disabled) return;
|
||||
const editorType = item.type === 'not_set' ? undefined : item.type;
|
||||
if (
|
||||
editorType &&
|
||||
(!checkHasEditorType(editorType) || !allowEditorTypeInSandbox(editorType))
|
||||
) {
|
||||
return;
|
||||
const editorItems: Array<
|
||||
DialogListItem<EditorType | 'not_set'> & {
|
||||
label: string;
|
||||
}
|
||||
try {
|
||||
settings.setValue(scope, 'general.preferredEditor', editorType);
|
||||
notify?.(
|
||||
`Editor preference ${editorType ? `set to "${editorType}"` : 'cleared'} in ${scope} settings.`,
|
||||
);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
useKeyboard((key) => {
|
||||
const o = toOriginalKey(key);
|
||||
if (o.name === 'tab') {
|
||||
setMode((m) => (m === 'editor' ? 'scope' : 'editor'));
|
||||
} else if (o.name === 'up' || o.name === 'down') {
|
||||
const d = o.name === 'up' ? -1 : 1;
|
||||
if (mode === 'editor') {
|
||||
moveEditor(d);
|
||||
} else {
|
||||
const next = Math.min(scopeItems.length - 1, Math.max(0, scopeSel + d));
|
||||
setScopeSel(next);
|
||||
highlightScope(next);
|
||||
> = editors.map((e) => ({
|
||||
key: e.type,
|
||||
value: e.type,
|
||||
label: e.name,
|
||||
disabled: e.disabled,
|
||||
}));
|
||||
const editorList = useDialogSelect({
|
||||
items: editorItems,
|
||||
initialIndex: scopeIndexOf(scope),
|
||||
focused: mode === 'editor',
|
||||
numbers: mode === 'editor',
|
||||
// ink keys its editor list by the selected scope, so coming back from the
|
||||
// scope step re-seats the cursor on that scope's stored preference.
|
||||
resyncKey: scope,
|
||||
onSelect: (picked) => {
|
||||
const editorType = picked === 'not_set' ? undefined : picked;
|
||||
// useEditorSettings.handleEditorSelect's guard: an editor that is not
|
||||
// installed, or not allowed in a sandbox, is not persisted.
|
||||
if (
|
||||
editorType &&
|
||||
(!checkHasEditorType(editorType) ||
|
||||
!allowEditorTypeInSandbox(editorType))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
} else if (o.name === 'return') {
|
||||
if (mode === 'editor') pick();
|
||||
else applyScope();
|
||||
}
|
||||
try {
|
||||
settings.setValue(scope, 'general.preferredEditor', editorType);
|
||||
notify?.(
|
||||
`Editor preference ${editorType ? `set to "${editorType}"` : 'cleared'} in ${scope} settings.`,
|
||||
);
|
||||
} catch (err) {
|
||||
setError(`Failed to set editor preference: ${err}`);
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
const scopeItems = useMemo(
|
||||
() =>
|
||||
getScopeItems().map((item) => ({
|
||||
key: item.value,
|
||||
value: item.value,
|
||||
label: t(item.label),
|
||||
})),
|
||||
[],
|
||||
);
|
||||
const initialScopeIndex = scopeItems.findIndex(
|
||||
(item) => item.value === scope,
|
||||
);
|
||||
const scopeList = useDialogSelect({
|
||||
items: scopeItems,
|
||||
initialIndex: initialScopeIndex >= 0 ? initialScopeIndex : 0,
|
||||
focused: mode === 'scope',
|
||||
numbers: mode === 'scope',
|
||||
// ink's handleScopeSelect only records the scope and steps back; the
|
||||
// editor row's Enter is what persists.
|
||||
onSelect: (next) => {
|
||||
setScope(next);
|
||||
setMode('editor');
|
||||
},
|
||||
// ink: highlighting a scope previews that scope's current preference.
|
||||
onHighlight: (next) => setScope(next),
|
||||
});
|
||||
|
||||
useDialogFrameKeys({
|
||||
onTab: () => setMode((m) => (m === 'editor' ? 'scope' : 'editor')),
|
||||
onEscape: onClose,
|
||||
});
|
||||
|
||||
const otherScope =
|
||||
@@ -226,83 +232,115 @@ export function OpenTuiEditorDialog({ settings, onClose, notify }: P) {
|
||||
? EDITOR_DISPLAY_NAMES[merged as EditorType]
|
||||
: 'None';
|
||||
|
||||
// ink's left column is 45% of the dialog's content width and pads two more
|
||||
// columns, and its hint is `wrap="truncate"` inside that box, so the budget
|
||||
// is the column's own rather than the dialog's.
|
||||
const hintWidth = Math.floor(dialogContentWidth(width) * 0.45) - 2;
|
||||
|
||||
return (
|
||||
<Shell title="Editor" onClose={onClose}>
|
||||
<box flexDirection="row" marginTop={1}>
|
||||
<box flexDirection="column" width="45%" paddingRight={2}>
|
||||
{mode === 'editor' ? (
|
||||
<box flexDirection="column">
|
||||
<box flexDirection="row">
|
||||
<box flexDirection="column">
|
||||
{error && (
|
||||
<box marginBottom={1}>
|
||||
<text fg={C.red}>{error}</text>
|
||||
</box>
|
||||
)}
|
||||
<DialogFrame>
|
||||
<box flexDirection="row">
|
||||
<box flexDirection="column" width="45%" paddingRight={2}>
|
||||
{mode === 'editor' ? (
|
||||
<box flexDirection="column">
|
||||
<box flexDirection="row">
|
||||
<text fg={C.text} attributes={1}>
|
||||
{'> '}
|
||||
{t('Select Editor')}{' '}
|
||||
</text>
|
||||
<text fg={C.dim}>{scopeMessage}</text>
|
||||
</box>
|
||||
<box flexDirection="column" marginTop={1}>
|
||||
<DialogSelect
|
||||
items={editorList.items}
|
||||
activeIndex={editorList.activeIndex}
|
||||
scrollOffset={editorList.scrollOffset}
|
||||
showNumbers={mode === 'editor'}
|
||||
focused={mode === 'editor'}
|
||||
onHover={editorList.setActiveIndex}
|
||||
onSelectIndex={editorList.selectIndex}
|
||||
onWheel={(direction) =>
|
||||
editorList.setActiveIndex(
|
||||
editorList.activeIndexRef.current +
|
||||
(direction === 'down' ? 1 : -1),
|
||||
)
|
||||
}
|
||||
renderLabel={(item, { titleColor }) => (
|
||||
<text fg={titleColor}>{item.label}</text>
|
||||
)}
|
||||
/>
|
||||
</box>
|
||||
</box>
|
||||
) : (
|
||||
<box flexDirection="column">
|
||||
<text fg={C.text} attributes={1}>
|
||||
{'> Select Editor '}
|
||||
{'> '}
|
||||
{t('Apply To')}
|
||||
</text>
|
||||
<text fg={C.dim}>{scopeMessage}</text>
|
||||
<box flexDirection="column" marginTop={1}>
|
||||
<DialogSelect
|
||||
items={scopeList.items}
|
||||
activeIndex={scopeList.activeIndex}
|
||||
scrollOffset={scopeList.scrollOffset}
|
||||
showNumbers={mode === 'scope'}
|
||||
focused={mode === 'scope'}
|
||||
onHover={scopeList.setActiveIndex}
|
||||
onSelectIndex={scopeList.selectIndex}
|
||||
onWheel={(direction) =>
|
||||
scopeList.setActiveIndex(
|
||||
scopeList.activeIndexRef.current +
|
||||
(direction === 'down' ? 1 : -1),
|
||||
)
|
||||
}
|
||||
renderLabel={(item, { titleColor }) => (
|
||||
<text fg={titleColor}>{item.label}</text>
|
||||
)}
|
||||
/>
|
||||
</box>
|
||||
</box>
|
||||
<box flexDirection="column" marginTop={1}>
|
||||
{editors.map((e, i) => (
|
||||
<box key={e.type} flexDirection="row">
|
||||
<text fg={i === sel ? C.accent : C.dim}>
|
||||
{i === sel ? '› ' : ' '}
|
||||
</text>
|
||||
<text
|
||||
fg={e.disabled ? C.dim : i === sel ? C.text : C.dim}
|
||||
attributes={!e.disabled && i === sel ? 1 : 0}
|
||||
>
|
||||
{e.name}
|
||||
</text>
|
||||
</box>
|
||||
))}
|
||||
</box>
|
||||
</box>
|
||||
) : (
|
||||
<box flexDirection="column">
|
||||
<text fg={C.text} attributes={1}>
|
||||
{'> Apply To'}
|
||||
)}
|
||||
<box marginTop={1}>
|
||||
<text fg={C.dim}>
|
||||
{truncateToWidth(
|
||||
mode === 'editor'
|
||||
? t('(Use Enter to select, Tab to configure scope)')
|
||||
: t('(Use Enter to apply scope, Tab to go back)'),
|
||||
hintWidth,
|
||||
)}
|
||||
</text>
|
||||
<box flexDirection="column" marginTop={1}>
|
||||
{scopeItems.map((s, i) => (
|
||||
<box key={s.value} flexDirection="row">
|
||||
<text fg={i === scopeSel ? C.accent : C.dim}>
|
||||
{i === scopeSel ? '› ' : ' '}
|
||||
</text>
|
||||
<text fg={i === scopeSel ? C.text : C.dim}>{s.label}</text>
|
||||
</box>
|
||||
))}
|
||||
</box>
|
||||
</box>
|
||||
)}
|
||||
<box marginTop={1}>
|
||||
<text fg={C.dim}>
|
||||
{mode === 'editor'
|
||||
? '(Use Enter to select, Tab to configure scope)'
|
||||
: '(Use Enter to apply scope, Tab to go back)'}
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
<box flexDirection="column" width="55%" paddingLeft={2}>
|
||||
<text fg={C.text} attributes={1}>
|
||||
{'Editor Preference'}
|
||||
</text>
|
||||
<box marginTop={1} flexDirection="column">
|
||||
<text fg={C.dim}>
|
||||
{
|
||||
'These editors are currently supported. Please note that some editors cannot be used in sandbox mode.'
|
||||
}
|
||||
<box flexDirection="column" width="55%" paddingLeft={2}>
|
||||
<text fg={C.text} attributes={1}>
|
||||
{t('Editor Preference')}
|
||||
</text>
|
||||
<box flexDirection="row">
|
||||
<text fg={C.dim}>{'Your preferred editor is: '}</text>
|
||||
<text
|
||||
fg={mergedName === 'None' ? C.red : C.accent}
|
||||
attributes={1}
|
||||
>
|
||||
{mergedName}
|
||||
<box marginTop={1} flexDirection="column">
|
||||
<text fg={C.dim}>
|
||||
{t(
|
||||
'These editors are currently supported. Please note that some editors cannot be used in sandbox mode.',
|
||||
)}
|
||||
</text>
|
||||
<text fg={C.dim}>{'.'}</text>
|
||||
<box flexDirection="row" marginTop={1}>
|
||||
<text fg={C.dim}>{`${t('Your preferred editor is:')} `}</text>
|
||||
<text
|
||||
fg={mergedName === 'None' ? C.red : C.purple}
|
||||
attributes={1}
|
||||
>
|
||||
{mergedName}
|
||||
</text>
|
||||
<text fg={C.dim}>{'.'}</text>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
</Shell>
|
||||
</DialogFrame>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -322,129 +360,51 @@ export function OpenTuiTrustDialog({ config, onClose }: P) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Real session deletion (audit 01 G-10): a session picker over
|
||||
* `SessionService.listSessions` (the current session is disabled, ink
|
||||
* parity) whose Enter runs `removeSession` + the SessionDelete hook —
|
||||
* the same services ink's useDeleteCommand drives.
|
||||
* Session deletion: the same picker ink's `/delete` mounts, driven by ink's own
|
||||
* `useDeleteCommand` so the single-delete guard, the batch-delete mutex and
|
||||
* every outcome message are shared rather than re-worded. The live session is
|
||||
* disabled in place instead of filtered out, matching ink.
|
||||
*/
|
||||
export function OpenTuiDeleteDialog({ config, onClose, notify }: P) {
|
||||
useEsc(onClose);
|
||||
const [rows, setRows] = useState<SessionListItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [cursor, setCursor] = useState(0);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const currentSessionId = config?.getSessionId?.() ?? '';
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const svc = config?.getSessionService?.();
|
||||
if (!svc) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
svc
|
||||
.listSessions({ size: 20 })
|
||||
.then((res) => {
|
||||
if (!alive) return;
|
||||
setRows(res.items ?? []);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => {
|
||||
if (alive) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [config]);
|
||||
const isDisabled = (row: SessionListItem) =>
|
||||
row.sessionId === currentSessionId;
|
||||
const move = (dir: 1 | -1) => {
|
||||
setCursor((prev) => {
|
||||
let next = prev;
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
next = (next + dir + rows.length) % rows.length;
|
||||
const row = rows[next];
|
||||
if (row && !isDisabled(row)) return next;
|
||||
const currentBranch = useGitBranchName(config?.getTargetDir() ?? '');
|
||||
const currentSessionId = config?.getSessionId() ?? '';
|
||||
const { handleDelete, handleDeleteMany } = useDeleteCommand({
|
||||
config: config ?? null,
|
||||
addItem: (item) => {
|
||||
if ('text' in item) {
|
||||
notify?.(String(item.text), item.type === 'error' ? 'error' : 'info');
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
};
|
||||
useKeyboard((key) => {
|
||||
const o = toOriginalKey(key);
|
||||
if (busy || loading) return;
|
||||
if (o.name === 'up') {
|
||||
move(-1);
|
||||
return;
|
||||
}
|
||||
if (o.name === 'down') {
|
||||
move(1);
|
||||
return;
|
||||
}
|
||||
if (o.name === 'return') {
|
||||
const row = rows[cursor];
|
||||
if (!row || isDisabled(row) || !config) return;
|
||||
setBusy(true);
|
||||
const svc = config.getSessionService();
|
||||
void svc
|
||||
.removeSession(row.sessionId)
|
||||
.then((success) => {
|
||||
if (success) {
|
||||
fireSessionDeleteHook(config, row.sessionId);
|
||||
notify?.('Session deleted successfully.');
|
||||
} else {
|
||||
notify?.('Failed to delete session. Session not found.');
|
||||
}
|
||||
onClose();
|
||||
})
|
||||
.catch(() => {
|
||||
notify?.('Failed to delete session.');
|
||||
setBusy(false);
|
||||
});
|
||||
}
|
||||
return 0;
|
||||
},
|
||||
});
|
||||
const disabledIds = useMemo(
|
||||
() => (currentSessionId ? [currentSessionId] : undefined),
|
||||
[currentSessionId],
|
||||
);
|
||||
return (
|
||||
<Shell title="Delete Session" onClose={onClose}>
|
||||
<box flexDirection="column" marginTop={1}>
|
||||
<text fg={C.dim}>
|
||||
{'Select a session to delete · enter to delete · esc to cancel'}
|
||||
</text>
|
||||
<scrollbox height={12} marginTop={1} stickyScroll={false}>
|
||||
{loading ? (
|
||||
<text fg={C.dim}>{'loading sessions…'}</text>
|
||||
) : rows.length === 0 ? (
|
||||
<text fg={C.dim}>{'no previous sessions'}</text>
|
||||
) : (
|
||||
rows.map((r, i) => {
|
||||
const disabled = isDisabled(r);
|
||||
const selected = i === cursor;
|
||||
const title = r.customTitle || r.prompt || '(untitled)';
|
||||
return (
|
||||
<box key={r.sessionId} flexDirection="row">
|
||||
<text fg={selected ? C.accent : C.dim}>
|
||||
{selected ? '› ' : ' '}
|
||||
</text>
|
||||
<text fg={disabled ? C.dim : selected ? C.accent : C.text}>
|
||||
{title.length > 40 ? `${title.slice(0, 40)}…` : title}
|
||||
</text>
|
||||
<text fg={C.dim}>{` ${r.sessionId.slice(0, 8)}${
|
||||
disabled ? ' (current)' : ''
|
||||
}`}</text>
|
||||
</box>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</scrollbox>
|
||||
{busy && <text fg={C.dim}>{'deleting…'}</text>}
|
||||
</box>
|
||||
</Shell>
|
||||
<OpenTuiSessionPicker
|
||||
sessionService={config?.getSessionService() ?? null}
|
||||
currentBranch={currentBranch}
|
||||
title={t('Delete Session')}
|
||||
onSelect={(sessionId) => {
|
||||
onClose();
|
||||
handleDelete(sessionId);
|
||||
}}
|
||||
onCancel={onClose}
|
||||
enableMultiSelect
|
||||
onConfirmMulti={(sessionIds) => {
|
||||
onClose();
|
||||
handleDeleteMany(sessionIds);
|
||||
}}
|
||||
disabledIds={disabledIds}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Interactive resume picker (audit 01 G-5 / 05 G-04): ↑↓ navigation + Enter
|
||||
* selects, running the real session switch through `onSelect`
|
||||
* (host.handleResume). `matchedSessions` is the command's pre-filtered list
|
||||
* (`/resume <fuzzy-title>` with multiple matches); without it the 10 most
|
||||
* recent sessions are listed, like ink's SessionPicker default.
|
||||
* Interactive resume picker: ink mounts the same `SessionPicker` here, with the
|
||||
* command's pre-filtered list when `/resume <fuzzy-title>` matched more than one
|
||||
* session and the paginated listing otherwise.
|
||||
*/
|
||||
export function OpenTuiResumeDialog({
|
||||
config,
|
||||
@@ -452,88 +412,19 @@ export function OpenTuiResumeDialog({
|
||||
matchedSessions,
|
||||
onSelect,
|
||||
}: P) {
|
||||
useEsc(onClose);
|
||||
const [rows, setRows] = useState<SessionListItem[]>(matchedSessions ?? []);
|
||||
const [loading, setLoading] = useState(!matchedSessions);
|
||||
const [cursor, setCursor] = useState(0);
|
||||
useEffect(() => {
|
||||
if (matchedSessions) {
|
||||
setRows(matchedSessions);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
let alive = true;
|
||||
const svc = config?.getSessionService?.();
|
||||
if (!svc) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
svc
|
||||
.listSessions({ size: 10 })
|
||||
.then((res) => {
|
||||
if (!alive) return;
|
||||
setRows(res.items ?? []);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => {
|
||||
if (alive) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [config, matchedSessions]);
|
||||
useKeyboard((key) => {
|
||||
const o = toOriginalKey(key);
|
||||
if (loading) return;
|
||||
if (o.name === 'up') {
|
||||
setCursor((prev) => Math.max(0, prev - 1));
|
||||
return;
|
||||
}
|
||||
if (o.name === 'down') {
|
||||
setCursor((prev) => Math.min(rows.length - 1, prev + 1));
|
||||
return;
|
||||
}
|
||||
if (o.name === 'return') {
|
||||
const row = rows[cursor];
|
||||
if (!row) return;
|
||||
onClose();
|
||||
onSelect?.(row.sessionId);
|
||||
}
|
||||
});
|
||||
const currentBranch = useGitBranchName(config?.getTargetDir() ?? '');
|
||||
return (
|
||||
<Shell title="Resume" onClose={onClose}>
|
||||
<box flexDirection="column" marginTop={1}>
|
||||
<text fg={C.dim}>
|
||||
{'↑↓ to navigate · enter to resume · esc to cancel'}
|
||||
</text>
|
||||
<scrollbox height={12} marginTop={1} stickyScroll={false}>
|
||||
{loading ? (
|
||||
<text fg={C.dim}>{'loading sessions…'}</text>
|
||||
) : rows.length === 0 ? (
|
||||
<text fg={C.dim}>{'no previous sessions'}</text>
|
||||
) : (
|
||||
rows.map((r, i) => {
|
||||
const selected = i === cursor;
|
||||
const title = r.customTitle || r.prompt || '(untitled)';
|
||||
const when = r.startTime ? r.startTime.slice(0, 10) : '';
|
||||
return (
|
||||
<box key={r.sessionId} flexDirection="row">
|
||||
<text fg={selected ? C.accent : C.dim}>
|
||||
{selected ? '› ' : ' '}
|
||||
</text>
|
||||
<text fg={selected ? C.accent : C.text}>
|
||||
{title.length > 40 ? `${title.slice(0, 40)}…` : title}
|
||||
</text>
|
||||
<text fg={C.dim}>{` ${r.sessionId.slice(0, 8)}${
|
||||
when ? ` · ${when}` : ''
|
||||
}`}</text>
|
||||
</box>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</scrollbox>
|
||||
</box>
|
||||
</Shell>
|
||||
<OpenTuiSessionPicker
|
||||
sessionService={config?.getSessionService() ?? null}
|
||||
currentBranch={currentBranch}
|
||||
initialSessions={matchedSessions}
|
||||
enablePreview
|
||||
onSelect={(sessionId) => {
|
||||
onClose();
|
||||
onSelect?.(sessionId);
|
||||
}}
|
||||
onCancel={onClose}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -336,7 +336,7 @@ export function OpenTuiModelDialog(props: OpenTuiModelDialogProps) {
|
||||
onSelectIndex={list.selectIndex}
|
||||
onWheel={(direction) =>
|
||||
list.setActiveIndex(
|
||||
list.activeIndex + (direction === 'down' ? 1 : -1),
|
||||
list.activeIndexRef.current + (direction === 'down' ? 1 : -1),
|
||||
)
|
||||
}
|
||||
renderLabel={(item, { titleColor }) => (
|
||||
|
||||
@@ -9,28 +9,18 @@
|
||||
import { act, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
ApprovalMode,
|
||||
BUILT_IN_OUTPUT_STYLES,
|
||||
type Config,
|
||||
type OutputStyleDefinition,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { ApprovalMode } from '@qwen-code/qwen-code-core/config/approval-mode.js';
|
||||
import { SettingScope, type LoadedSettings } from '../../config/settings.js';
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const loadSessionOutputStyles = vi.fn();
|
||||
const state = {
|
||||
inputHandlers: [] as Array<(sequence: string) => boolean>,
|
||||
keyboardHandlers: [] as Array<(key: unknown) => void>,
|
||||
};
|
||||
const renderer = {
|
||||
addInputHandler(handler: (sequence: string) => boolean) {
|
||||
state.inputHandlers.push(handler);
|
||||
},
|
||||
removeInputHandler(handler: (sequence: string) => boolean) {
|
||||
const index = state.inputHandlers.indexOf(handler);
|
||||
if (index >= 0) state.inputHandlers.splice(index, 1);
|
||||
},
|
||||
};
|
||||
async function buildJsxRuntime() {
|
||||
const React = await import('react');
|
||||
const jsx = (
|
||||
@@ -55,19 +45,39 @@ const mocks = vi.hoisted(() => {
|
||||
};
|
||||
return { jsx, jsxs: jsx, jsxDEV: jsx, Fragment: React.Fragment };
|
||||
}
|
||||
return { state, renderer, buildJsxRuntime, loadSessionOutputStyles };
|
||||
return { state, buildJsxRuntime, loadSessionOutputStyles };
|
||||
});
|
||||
|
||||
vi.mock('@opentui/react', () => ({
|
||||
useKeyboard: (handler: (key: unknown) => void) => {
|
||||
mocks.state.keyboardHandlers.push(handler);
|
||||
},
|
||||
useRenderer: () => mocks.renderer,
|
||||
vi.mock('@opentui/react', async () => {
|
||||
const React = await import('react');
|
||||
return {
|
||||
// opentui registers one stable listener per hook instance (useEffectEvent)
|
||||
// on an emitter that fires for every listener, so each mounted list sees
|
||||
// each key and its own `focused` flag decides whether it acts.
|
||||
useKeyboard: (handler: (key: unknown) => void) => {
|
||||
const latest = React.useRef(handler);
|
||||
latest.current = handler;
|
||||
const stable = React.useRef<((key: unknown) => void) | undefined>(
|
||||
undefined,
|
||||
);
|
||||
if (!stable.current) {
|
||||
stable.current = (key: unknown) => latest.current(key);
|
||||
mocks.state.keyboardHandlers.push(stable.current);
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
vi.mock('@opentui/core', () => ({
|
||||
SyntaxStyle: { fromStyles: () => ({}) },
|
||||
MouseButton: { LEFT: 0 },
|
||||
}));
|
||||
vi.mock('@opentui/react/jsx-runtime', () => mocks.buildJsxRuntime());
|
||||
vi.mock('@opentui/react/jsx-dev-runtime', () => mocks.buildJsxRuntime());
|
||||
vi.mock('./key-map.js', () => ({
|
||||
toOriginalKey: (key: { name?: string }) => ({ name: key.name ?? '' }),
|
||||
toOriginalKey: (key: { name?: string; shift?: boolean }) => ({
|
||||
name: key.name ?? '',
|
||||
shift: key.shift ?? false,
|
||||
}),
|
||||
}));
|
||||
vi.mock('./theme.js', () => ({
|
||||
C: new Proxy({}, { get: () => '#ffffff' }),
|
||||
@@ -92,6 +102,35 @@ const CONCISE = BUILT_IN_OUTPUT_STYLES.find(
|
||||
);
|
||||
if (!CONCISE) throw new Error('missing Concise output style');
|
||||
|
||||
function press(name: string, shift = false) {
|
||||
if (mocks.state.keyboardHandlers.length === 0) {
|
||||
throw new Error('no keyboard handler registered');
|
||||
}
|
||||
act(() => {
|
||||
for (const handler of [...mocks.state.keyboardHandlers]) {
|
||||
handler({ name, shift });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** The row's text: marker, then the number column, then the label. */
|
||||
function rowText(labelPrefix: string): string {
|
||||
const label = screen.getByText((content) =>
|
||||
content.startsWith(labelPrefix),
|
||||
) as HTMLElement;
|
||||
return (label.parentElement?.parentElement?.textContent ?? '').trim();
|
||||
}
|
||||
|
||||
function isSelected(labelPrefix: string): boolean {
|
||||
return rowText(labelPrefix).startsWith('›');
|
||||
}
|
||||
|
||||
function queryRow(labelPrefix: string): string | null {
|
||||
return screen.queryByText((content) => content.startsWith(labelPrefix))
|
||||
? rowText(labelPrefix)
|
||||
: null;
|
||||
}
|
||||
|
||||
function createHarness(
|
||||
options: {
|
||||
current?: OutputStyleDefinition;
|
||||
@@ -130,25 +169,117 @@ function createHarness(
|
||||
};
|
||||
}
|
||||
|
||||
function press(name: string) {
|
||||
const handler = mocks.state.keyboardHandlers.at(-1);
|
||||
if (!handler) throw new Error('no keyboard handler registered');
|
||||
act(() => handler({ name }));
|
||||
}
|
||||
|
||||
async function pressEsc(): Promise<boolean> {
|
||||
const handler = mocks.state.inputHandlers.at(-1);
|
||||
if (!handler) throw new Error('no raw input handler registered');
|
||||
let consumed = false;
|
||||
await act(async () => {
|
||||
consumed = handler('\x1b');
|
||||
});
|
||||
return consumed;
|
||||
}
|
||||
|
||||
describe('OpenTuiApprovalModeDialog', () => {
|
||||
function renderModeDialog(options: { current?: ApprovalMode } = {}) {
|
||||
const setValue = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
const onApprovalModeChanged = vi.fn();
|
||||
let approvalMode = options.current ?? ApprovalMode.DEFAULT;
|
||||
const config = {
|
||||
getApprovalMode: () => approvalMode,
|
||||
isTrustedFolder: () => true,
|
||||
setApprovalMode: (mode: ApprovalMode) => {
|
||||
approvalMode = mode;
|
||||
},
|
||||
} as unknown as Config;
|
||||
const settings = {
|
||||
isTrusted: true,
|
||||
merged: { tools: {} },
|
||||
forScope: () => ({ settings: {} }),
|
||||
setValue,
|
||||
} as unknown as LoadedSettings;
|
||||
render(
|
||||
<OpenTuiApprovalModeDialog
|
||||
config={config}
|
||||
settings={settings}
|
||||
onClose={onClose}
|
||||
onApprovalModeChanged={onApprovalModeChanged}
|
||||
/>,
|
||||
);
|
||||
return { setValue, onClose, onApprovalModeChanged };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.state.keyboardHandlers.length = 0;
|
||||
});
|
||||
|
||||
it("labels every mode with ink's display name, description and row number", () => {
|
||||
renderModeDialog({ current: ApprovalMode.YOLO });
|
||||
|
||||
// ink builds `${formatApprovalModeName} - ${formatApprovalModeDescription}`
|
||||
// and numbers the rows; a hand-written label set drifts from both.
|
||||
expect(rowText('plan mode - ')).toBe(
|
||||
'1.plan mode - Analyze only, do not modify files or execute commands',
|
||||
);
|
||||
expect(rowText('YOLO mode - ')).toBe(
|
||||
'›5.YOLO mode - Automatically approve all tools',
|
||||
);
|
||||
expect(isSelected('YOLO mode - ')).toBe(true);
|
||||
});
|
||||
|
||||
it('wraps from the last row to the first, like ink useSelectionList', () => {
|
||||
renderModeDialog({ current: ApprovalMode.YOLO });
|
||||
|
||||
press('down');
|
||||
|
||||
expect(isSelected('plan mode - ')).toBe(true);
|
||||
});
|
||||
|
||||
it('persists to the scope picked in the Tab step', () => {
|
||||
const harness = renderModeDialog({ current: ApprovalMode.DEFAULT });
|
||||
|
||||
press('tab');
|
||||
expect(queryRow('Workspace Settings')).not.toBeNull();
|
||||
press('down');
|
||||
press('return');
|
||||
|
||||
// ink's handleScopeSelect only records the scope and steps back; the mode
|
||||
// row's Enter is what writes.
|
||||
expect(harness.setValue).not.toHaveBeenCalled();
|
||||
expect(queryRow('Ask permissions - ')).not.toBeNull();
|
||||
|
||||
press('down');
|
||||
press('return');
|
||||
|
||||
expect(harness.setValue).toHaveBeenCalledWith(
|
||||
SettingScope.Workspace,
|
||||
'tools.approvalMode',
|
||||
ApprovalMode.AUTO_EDIT,
|
||||
);
|
||||
expect(harness.onApprovalModeChanged).toHaveBeenCalledWith(
|
||||
ApprovalMode.AUTO_EDIT,
|
||||
);
|
||||
expect(harness.onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('restores the highlighted row after the scope trip, like ink', () => {
|
||||
renderModeDialog({ current: ApprovalMode.DEFAULT });
|
||||
|
||||
press('down'); // Ask permissions → auto-accept edits
|
||||
expect(isSelected('auto-accept edits - ')).toBe(true);
|
||||
|
||||
press('tab'); // → scope step
|
||||
press('down'); // User Settings → Workspace, so the trip actually changes the scope
|
||||
press('return'); // → mode step, which re-syncs the list cursor
|
||||
|
||||
// ink seeds the remounted list from the mode its arrows last highlighted,
|
||||
// not from the mode the config still holds.
|
||||
expect(isSelected('auto-accept edits - ')).toBe(true);
|
||||
expect(isSelected('Ask permissions - ')).toBe(false);
|
||||
});
|
||||
|
||||
it('closes on Esc without writing', () => {
|
||||
const harness = renderModeDialog();
|
||||
|
||||
press('escape');
|
||||
|
||||
expect(harness.onClose).toHaveBeenCalledTimes(1);
|
||||
expect(harness.setValue).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('OpenTuiApprovalModeDialog trust gate', () => {
|
||||
beforeEach(() => {
|
||||
mocks.state.inputHandlers.length = 0;
|
||||
mocks.state.keyboardHandlers.length = 0;
|
||||
});
|
||||
|
||||
@@ -161,6 +292,7 @@ describe('OpenTuiApprovalModeDialog', () => {
|
||||
} as unknown as Config;
|
||||
const settings = {
|
||||
merged: { tools: { approvalMode: ApprovalMode.DEFAULT } },
|
||||
forScope: () => ({ settings: {} }),
|
||||
setValue: vi.fn(),
|
||||
} as unknown as LoadedSettings;
|
||||
const onApprovalModeChanged = vi.fn();
|
||||
@@ -189,6 +321,7 @@ describe('OpenTuiApprovalModeDialog', () => {
|
||||
} as unknown as Config;
|
||||
const settings = {
|
||||
merged: { tools: {} },
|
||||
forScope: () => ({ settings: {} }),
|
||||
setValue,
|
||||
} as unknown as LoadedSettings;
|
||||
|
||||
@@ -214,7 +347,6 @@ describe('OpenTuiApprovalModeDialog', () => {
|
||||
|
||||
describe('OpenTuiOutputStyleDialog', () => {
|
||||
beforeEach(() => {
|
||||
mocks.state.inputHandlers.length = 0;
|
||||
mocks.state.keyboardHandlers.length = 0;
|
||||
mocks.loadSessionOutputStyles.mockReset();
|
||||
mocks.loadSessionOutputStyles.mockResolvedValue(BUILT_IN_OUTPUT_STYLES);
|
||||
@@ -250,19 +382,10 @@ describe('OpenTuiOutputStyleDialog', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.queryByText('Reviewer')).not.toBeNull());
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('Reviewer').parentElement?.textContent).toContain(
|
||||
'› Reviewer',
|
||||
),
|
||||
);
|
||||
await waitFor(() => expect(isSelected('Reviewer — ')).toBe(true));
|
||||
// Labelled with its source, as the ink picker does.
|
||||
expect(screen.getByText('Reviewer').parentElement?.textContent).toContain(
|
||||
'(user)',
|
||||
);
|
||||
expect(
|
||||
screen.getByText('default').parentElement?.textContent,
|
||||
).not.toContain('› default');
|
||||
expect(rowText('Reviewer — ')).toContain('(user)');
|
||||
expect(isSelected('default — ')).toBe(false);
|
||||
});
|
||||
|
||||
it('labels a project style with its own source and leaves built-ins unlabelled', async () => {
|
||||
@@ -289,13 +412,9 @@ describe('OpenTuiOutputStyleDialog', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.queryByText('TeamVoice')).not.toBeNull());
|
||||
expect(screen.getByText('TeamVoice').parentElement?.textContent).toContain(
|
||||
'(project)',
|
||||
);
|
||||
expect(
|
||||
screen.getByText('Concise').parentElement?.textContent,
|
||||
).not.toContain('(');
|
||||
await waitFor(() => expect(queryRow('TeamVoice — ')).not.toBeNull());
|
||||
expect(rowText('TeamVoice — ')).toContain('(project)');
|
||||
expect(rowText('Concise — ')).not.toContain('(');
|
||||
});
|
||||
|
||||
it('keeps the configured style selected while a system prompt override is active', async () => {
|
||||
@@ -314,11 +433,7 @@ describe('OpenTuiOutputStyleDialog', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('Concise').parentElement?.textContent).toContain(
|
||||
'› Concise',
|
||||
),
|
||||
);
|
||||
await waitFor(() => expect(isSelected('Concise — ')).toBe(true));
|
||||
press('return');
|
||||
|
||||
await waitFor(() =>
|
||||
@@ -349,7 +464,7 @@ describe('OpenTuiOutputStyleDialog', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.queryByText('Concise')).not.toBeNull());
|
||||
await waitFor(() => expect(queryRow('Concise — ')).not.toBeNull());
|
||||
press('down');
|
||||
press('return');
|
||||
|
||||
@@ -371,11 +486,7 @@ describe('OpenTuiOutputStyleDialog', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('Concise').parentElement?.textContent).toContain(
|
||||
'› Concise',
|
||||
),
|
||||
);
|
||||
await waitFor(() => expect(isSelected('Concise — ')).toBe(true));
|
||||
press('return');
|
||||
|
||||
await waitFor(() =>
|
||||
@@ -406,14 +517,10 @@ describe('OpenTuiOutputStyleDialog', () => {
|
||||
|
||||
// Wait for the selection marker, not just the row: the catalog text
|
||||
// renders with the mount-time selection (index 0) and the pre-selection
|
||||
// of the active style lands in a later passive-effect commit. Pressing
|
||||
// keys on text presence alone can interleave as up-then-derive-then-
|
||||
// return, which picks Concise instead of default.
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('Concise').parentElement?.textContent).toContain(
|
||||
'› Concise',
|
||||
),
|
||||
);
|
||||
// of the active style lands in a later commit. Pressing keys on text
|
||||
// presence alone can interleave as up-then-derive-then-return, which
|
||||
// picks Concise instead of default.
|
||||
await waitFor(() => expect(isSelected('Concise — ')).toBe(true));
|
||||
press('up');
|
||||
press('return');
|
||||
|
||||
@@ -441,13 +548,14 @@ describe('OpenTuiOutputStyleDialog', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await pressEsc()).toBe(true);
|
||||
press('escape');
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
expect(harness.setOutputStyle).not.toHaveBeenCalled();
|
||||
expect(harness.setValue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not mount selectable rows before the catalog is ready', async () => {
|
||||
it('does not offer selectable rows before the catalog is ready', async () => {
|
||||
const custom: OutputStyleDefinition = {
|
||||
name: 'Reviewer',
|
||||
description: 'Reviews without editing',
|
||||
@@ -473,19 +581,17 @@ describe('OpenTuiOutputStyleDialog', () => {
|
||||
);
|
||||
|
||||
expect(screen.queryByText('Loading output styles…')).not.toBeNull();
|
||||
expect(mocks.state.keyboardHandlers).toHaveLength(0);
|
||||
expect(screen.queryByText('default')).toBeNull();
|
||||
expect(screen.queryByText(/default — /)).toBeNull();
|
||||
// The list is mounted but empty, so Enter has no row to commit.
|
||||
press('return');
|
||||
expect(harness.setOutputStyle).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
releaseLoad?.();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('Reviewer').parentElement?.textContent).toContain(
|
||||
'› Reviewer',
|
||||
),
|
||||
);
|
||||
await waitFor(() => expect(isSelected('Reviewer — ')).toBe(true));
|
||||
expect(harness.setOutputStyle).not.toHaveBeenCalled();
|
||||
expect(harness.setValue).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -505,8 +611,11 @@ describe('OpenTuiOutputStyleDialog', () => {
|
||||
);
|
||||
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1));
|
||||
expect(notify).toHaveBeenCalledWith(expect.stringContaining('EACCES'));
|
||||
expect(screen.queryByText('default')).toBeNull();
|
||||
expect(notify).toHaveBeenCalledWith(
|
||||
expect.stringContaining('EACCES'),
|
||||
'error',
|
||||
);
|
||||
expect(screen.queryByText(/default — /)).toBeNull();
|
||||
expect(harness.setOutputStyle).not.toHaveBeenCalled();
|
||||
expect(harness.setValue).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -526,10 +635,19 @@ describe('OpenTuiOutputStyleDialog', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.queryByText('Concise')).not.toBeNull());
|
||||
await waitFor(() => expect(queryRow('Concise — ')).not.toBeNull());
|
||||
press('return');
|
||||
|
||||
await waitFor(() => expect(notify).toHaveBeenCalledWith('disk full'));
|
||||
await waitFor(() =>
|
||||
expect(notify).toHaveBeenCalledWith(
|
||||
expect.stringContaining('disk full'),
|
||||
'error',
|
||||
),
|
||||
);
|
||||
expect(notify).toHaveBeenCalledWith(
|
||||
expect.stringContaining('general.outputStyle'),
|
||||
'error',
|
||||
);
|
||||
expect(harness.setOutputStyle).not.toHaveBeenCalled();
|
||||
expect(harness.refreshSystemInstruction).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -552,11 +670,9 @@ describe('OpenTuiOutputStyleDialog', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.queryByText('Concise')).not.toBeNull());
|
||||
await waitFor(() => expect(queryRow('Concise — ')).not.toBeNull());
|
||||
press('down');
|
||||
expect(screen.getByText('Concise').parentElement?.textContent).toContain(
|
||||
'› Concise',
|
||||
);
|
||||
expect(isSelected('Concise — ')).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
view.rerender(
|
||||
@@ -572,9 +688,7 @@ describe('OpenTuiOutputStyleDialog', () => {
|
||||
});
|
||||
|
||||
expect(mocks.loadSessionOutputStyles).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByText('Concise').parentElement?.textContent).toContain(
|
||||
'› Concise',
|
||||
);
|
||||
expect(isSelected('Concise — ')).toBe(true);
|
||||
press('return');
|
||||
await waitFor(() =>
|
||||
expect(harness.setOutputStyle).toHaveBeenCalledWith(CONCISE),
|
||||
@@ -605,14 +719,8 @@ describe('OpenTuiOutputStyleDialog', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('Reviewer').parentElement?.textContent).toContain(
|
||||
'› Reviewer',
|
||||
),
|
||||
);
|
||||
expect(
|
||||
screen.getByText('default').parentElement?.textContent,
|
||||
).not.toContain('› default');
|
||||
await waitFor(() => expect(isSelected('Reviewer — ')).toBe(true));
|
||||
expect(isSelected('default — ')).toBe(false);
|
||||
|
||||
press('return');
|
||||
await waitFor(() =>
|
||||
@@ -651,13 +759,11 @@ describe('OpenTuiOutputStyleDialog', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('reviewer').parentElement?.textContent).toContain(
|
||||
'› reviewer',
|
||||
),
|
||||
);
|
||||
expect(screen.getAllByText('reviewer')).toHaveLength(1);
|
||||
expect(screen.queryByText('Reviewer')).toBeNull();
|
||||
await waitFor(() => expect(isSelected('reviewer — ')).toBe(true));
|
||||
expect(
|
||||
screen.getAllByText((content) => content.startsWith('reviewer — ')),
|
||||
).toHaveLength(1);
|
||||
expect(queryRow('Reviewer — ')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -669,38 +775,60 @@ describe('OpenTuiEffortDialog', () => {
|
||||
disableField: 'thinking',
|
||||
} as const;
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.state.keyboardHandlers.length = 0;
|
||||
});
|
||||
|
||||
function renderEffortDialog(reasoningEffort: string | undefined) {
|
||||
const setValue = vi.fn();
|
||||
const notify = vi.fn();
|
||||
let applied = reasoningEffort;
|
||||
const setReasoningEffort = vi.fn((tier: string) => {
|
||||
applied = tier;
|
||||
});
|
||||
const config = {
|
||||
getModel: () => 'deepseek-v4-pro',
|
||||
getAuthType: () => 'openai',
|
||||
getReasoningEffort: () => reasoningEffort,
|
||||
getReasoningEffort: () => applied,
|
||||
setReasoningEffort,
|
||||
getResolvedModelConfig: () => ({
|
||||
capabilities: { reasoning: capability },
|
||||
}),
|
||||
} as unknown as Config;
|
||||
const settings = {
|
||||
isTrusted: true,
|
||||
user: { settings: {} },
|
||||
workspace: { settings: { general: {} } },
|
||||
setValue: vi.fn(),
|
||||
merged: {},
|
||||
setValue,
|
||||
} as unknown as LoadedSettings;
|
||||
render(
|
||||
<OpenTuiEffortDialog
|
||||
config={config}
|
||||
settings={settings}
|
||||
onClose={vi.fn()}
|
||||
notify={notify}
|
||||
/>,
|
||||
);
|
||||
return { setValue, setReasoningEffort, notify };
|
||||
}
|
||||
|
||||
it('lists only the tiers the resolved model exposes', () => {
|
||||
it('lists only the tiers the resolved model exposes, with ink labels', () => {
|
||||
renderEffortDialog(undefined);
|
||||
|
||||
expect(screen.queryByText('low')).toBeNull();
|
||||
expect(screen.queryByText('medium')).toBeNull();
|
||||
expect(screen.queryByText('xhigh')).toBeNull();
|
||||
expect(screen.getByText('high').parentElement?.textContent).toContain(
|
||||
'\u203a high',
|
||||
expect(queryRow('low — ')).toBeNull();
|
||||
expect(queryRow('medium — ')).toBeNull();
|
||||
expect(queryRow('xhigh — ')).toBeNull();
|
||||
expect(rowText('high — ')).toBe(
|
||||
'›1.high — Default — strong reasoning for hard tasks.',
|
||||
);
|
||||
// No tier is configured, so the picker says so rather than implying that
|
||||
// the highlighted row is live.
|
||||
expect(
|
||||
screen.getByText(
|
||||
'No effort configured — using the model/provider default.',
|
||||
),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it('reports a configured tier the resolved model does not expose', () => {
|
||||
@@ -711,5 +839,34 @@ describe('OpenTuiEffortDialog', () => {
|
||||
expect(
|
||||
screen.getByText(/xhigh is not available for this model/),
|
||||
).not.toBeNull();
|
||||
// ink clamps to the first row and lets that dim line carry the truth.
|
||||
expect(isSelected('high — ')).toBe(true);
|
||||
});
|
||||
|
||||
it('wraps from the last tier to the first and persists the reached row', () => {
|
||||
const harness = renderEffortDialog('max');
|
||||
|
||||
expect(isSelected('max — ')).toBe(true);
|
||||
press('down');
|
||||
expect(isSelected('high — ')).toBe(true);
|
||||
press('return');
|
||||
|
||||
expect(harness.setReasoningEffort).toHaveBeenCalledWith('high');
|
||||
expect(harness.setValue).toHaveBeenCalledWith(
|
||||
SettingScope.User,
|
||||
'model.reasoningEffort',
|
||||
'high',
|
||||
);
|
||||
});
|
||||
|
||||
it('reports the applied tier with ink effort-hook message', () => {
|
||||
const harness = renderEffortDialog('max');
|
||||
press('down');
|
||||
press('return');
|
||||
|
||||
expect(harness.notify).toHaveBeenCalledTimes(1);
|
||||
expect(harness.notify).toHaveBeenCalledWith(
|
||||
'Reasoning effort: high (requested; the effective tier depends on the active provider/model).',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -7,19 +7,14 @@
|
||||
*/
|
||||
|
||||
/**
|
||||
* Native OpenTUI ApprovalMode and Effort dialogs (parity follow-up to #8677),
|
||||
* ported from ink ApprovalModeDialog/EffortDialog: a radio list navigated with
|
||||
* up/down, Enter applies (settings + config), Esc cancels.
|
||||
* Native OpenTUI ApprovalMode / Reasoning Effort / Output Style dialogs,
|
||||
* ported from ink ApprovalModeDialog / EffortDialog / OutputStyleDialog onto
|
||||
* the shared dialog primitives: `> Title` plus a dim subtitle, numbered radio
|
||||
* rows carrying ink's own label text, ink's footer hint, and the approval
|
||||
* dialog's Tab-reachable scope step.
|
||||
*/
|
||||
|
||||
import {
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import { useRenderer, useKeyboard } from '@opentui/react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
APPROVAL_MODES,
|
||||
ApprovalMode,
|
||||
@@ -33,99 +28,80 @@ import {
|
||||
import type { ReasoningEffort } from '@qwen-code/qwen-code-core/core/reasoning-effort.js';
|
||||
import { SettingScope, type LoadedSettings } from '../../config/settings.js';
|
||||
import { getPersistScopeForModelSelection } from '../../config/modelProvidersScope.js';
|
||||
import {
|
||||
getScopeItems,
|
||||
getScopeMessageForSetting,
|
||||
} from '../../config/dialogScopeUtils.js';
|
||||
import {
|
||||
applyOutputStyleSelection,
|
||||
loadSessionOutputStyles,
|
||||
} from '../commands/output-style-utils.js';
|
||||
import { toOriginalKey } from './key-map.js';
|
||||
import { formatEffortChangeMessage } from '../commands/effort-utils.js';
|
||||
import { EFFORT_DESCRIPTIONS } from '../components/EffortDialog.js';
|
||||
import {
|
||||
formatApprovalModeDescription,
|
||||
formatApprovalModeName,
|
||||
} from '../utils/approvalModeDisplay.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import {
|
||||
DialogFrame,
|
||||
DialogSelect,
|
||||
FooterHint,
|
||||
useDialogFrameKeys,
|
||||
useDialogSelect,
|
||||
type UseDialogSelectResult,
|
||||
} from './dialogs-shared.js';
|
||||
import type { DialogListItem } from './dialogs-core.js';
|
||||
import { C } from './theme.js';
|
||||
import { getReasoningEffortsForConfig } from '../../acp-integration/model-configuration.js';
|
||||
|
||||
function useEsc(onClose: () => void) {
|
||||
const renderer = useRenderer();
|
||||
useLayoutEffect(() => {
|
||||
const onRaw = (seq: string): boolean => {
|
||||
if (seq !== '\x1b') return false;
|
||||
onClose();
|
||||
return true;
|
||||
};
|
||||
renderer.addInputHandler(onRaw);
|
||||
return () => renderer.removeInputHandler(onRaw);
|
||||
}, [renderer, onClose]);
|
||||
interface LabeledItem<T> extends DialogListItem<T> {
|
||||
label: string;
|
||||
}
|
||||
|
||||
function RadioList({
|
||||
items,
|
||||
selected,
|
||||
onMove,
|
||||
onPick,
|
||||
}: {
|
||||
items: Array<{ key: string; label: string; desc?: string }>;
|
||||
selected: number;
|
||||
onMove: (d: 1 | -1) => void;
|
||||
onPick: () => void;
|
||||
/**
|
||||
* The ink dialogs build one `name — description` string per row and let
|
||||
* BaseSelectionList colour it as a whole, so the label is a single text run
|
||||
* here too rather than a name/description pair.
|
||||
*/
|
||||
function LabeledRows<T>(props: {
|
||||
list: UseDialogSelectResult<LabeledItem<T>>;
|
||||
focused: boolean;
|
||||
}) {
|
||||
useKeyboard((key) => {
|
||||
const o = toOriginalKey(key);
|
||||
if (o.name === 'up') onMove(-1);
|
||||
else if (o.name === 'down') onMove(1);
|
||||
else if (o.name === 'return') onPick();
|
||||
});
|
||||
const { list, focused } = props;
|
||||
return (
|
||||
<box flexDirection="column" marginTop={1}>
|
||||
{items.map((it, i) => (
|
||||
<box key={it.key} flexDirection="row">
|
||||
<text fg={i === selected ? C.accent : C.dim}>
|
||||
{i === selected ? '› ' : ' '}
|
||||
</text>
|
||||
<text
|
||||
fg={i === selected ? C.text : C.dim}
|
||||
attributes={i === selected ? 1 : 0}
|
||||
>
|
||||
{it.label}
|
||||
</text>
|
||||
{it.desc ? <text fg={C.dim}>{` ${it.desc}`}</text> : null}
|
||||
</box>
|
||||
))}
|
||||
</box>
|
||||
<DialogSelect
|
||||
items={list.items}
|
||||
activeIndex={list.activeIndex}
|
||||
scrollOffset={list.scrollOffset}
|
||||
showNumbers={focused}
|
||||
focused={focused}
|
||||
onHover={list.setActiveIndex}
|
||||
onSelectIndex={list.selectIndex}
|
||||
onWheel={(direction) =>
|
||||
list.setActiveIndex(
|
||||
list.activeIndexRef.current + (direction === 'down' ? 1 : -1),
|
||||
)
|
||||
}
|
||||
renderLabel={(item, { titleColor }) => (
|
||||
<text fg={titleColor}>{item.label}</text>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const Shell = ({
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
children?: ReactNode;
|
||||
}) => (
|
||||
<box
|
||||
flexDirection="column"
|
||||
border
|
||||
borderColor={C.dim}
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
marginTop={1}
|
||||
flexShrink={0}
|
||||
>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text fg={C.accent} attributes={1}>
|
||||
{title}
|
||||
/** The `> Title <dim subtitle>` row every ink dialog opens with. */
|
||||
function DialogTitle(props: { title: string; subtitle?: string }) {
|
||||
return (
|
||||
<box flexDirection="row" marginBottom={1}>
|
||||
<text fg={C.text} attributes={1}>
|
||||
{'> '}
|
||||
{props.title}{' '}
|
||||
</text>
|
||||
<text fg={C.dim}>{'↑↓ · enter · esc'}</text>
|
||||
{props.subtitle ? <text fg={C.dim}>{props.subtitle}</text> : null}
|
||||
</box>
|
||||
{children}
|
||||
</box>
|
||||
);
|
||||
|
||||
const MODE_DESC: Record<string, string> = {
|
||||
default: 'Prompt for each tool',
|
||||
'auto-edit': 'Auto-approve edits',
|
||||
auto: 'Full auto, safer rules',
|
||||
yolo: 'Auto-approve everything',
|
||||
plan: 'Plan only, no execution',
|
||||
};
|
||||
);
|
||||
}
|
||||
|
||||
export function OpenTuiApprovalModeDialog(props: {
|
||||
config?: Config;
|
||||
@@ -134,80 +110,151 @@ export function OpenTuiApprovalModeDialog(props: {
|
||||
onApprovalModeChanged: (m: ApprovalMode) => void;
|
||||
}) {
|
||||
const { config, settings, onClose, onApprovalModeChanged } = props;
|
||||
const modes = APPROVAL_MODES as ApprovalMode[];
|
||||
const current = config?.getApprovalMode?.();
|
||||
const [sel, setSel] = useState(
|
||||
Math.max(0, modes.indexOf(current as ApprovalMode)),
|
||||
const [view, setView] = useState<'mode' | 'scope'>('mode');
|
||||
const [selectedScope, setSelectedScope] = useState<SettingScope>(
|
||||
SettingScope.User,
|
||||
);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
useEsc(onClose);
|
||||
const pick = () => {
|
||||
const mode = modes[sel];
|
||||
if (!mode) {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// Do not persist a privileged mode that this workspace cannot use;
|
||||
// User scope would make it active in other trusted workspaces.
|
||||
if (
|
||||
config?.isTrustedFolder() === false &&
|
||||
mode !== ApprovalMode.DEFAULT &&
|
||||
mode !== ApprovalMode.PLAN
|
||||
) {
|
||||
throw new Error(
|
||||
'Cannot enable privileged approval modes in an untrusted folder.',
|
||||
);
|
||||
const current = config?.getApprovalMode?.() ?? ApprovalMode.DEFAULT;
|
||||
// ink keeps its own highlighted-mode state and seeds the list index from it,
|
||||
// so the remount that follows a scope trip restores the row the arrows last
|
||||
// landed on rather than the mode the config happens to hold.
|
||||
const [highlightedMode, setHighlightedMode] = useState<ApprovalMode>(current);
|
||||
|
||||
const modeItems: Array<LabeledItem<ApprovalMode>> = APPROVAL_MODES.map(
|
||||
(mode) => ({
|
||||
key: mode,
|
||||
value: mode,
|
||||
label: `${formatApprovalModeName(mode)} - ${formatApprovalModeDescription(
|
||||
mode,
|
||||
)}`,
|
||||
}),
|
||||
);
|
||||
const modeList = useDialogSelect<LabeledItem<ApprovalMode>>({
|
||||
items: modeItems,
|
||||
initialIndex: Math.max(
|
||||
0,
|
||||
modeItems.findIndex((item) => item.value === highlightedMode),
|
||||
),
|
||||
focused: view === 'mode',
|
||||
numbers: view === 'mode',
|
||||
// The scope step's close remounts this list; the highlighted mode is what
|
||||
// survives that trip, and the scope is what changes on it.
|
||||
resyncKey: selectedScope,
|
||||
onHighlight: (mode) => setHighlightedMode(mode),
|
||||
onSelect: (mode) => {
|
||||
try {
|
||||
// Do not persist a privileged mode that this workspace cannot use;
|
||||
// User scope would make it active in other trusted workspaces.
|
||||
if (
|
||||
config?.isTrustedFolder() === false &&
|
||||
mode !== ApprovalMode.DEFAULT &&
|
||||
mode !== ApprovalMode.PLAN
|
||||
) {
|
||||
throw new Error(
|
||||
'Cannot enable privileged approval modes in an untrusted folder.',
|
||||
);
|
||||
}
|
||||
settings.setValue(selectedScope, 'tools.approvalMode', mode);
|
||||
const effectiveMode = settings.merged.tools?.approvalMode ?? mode;
|
||||
config?.setApprovalMode?.(effectiveMode);
|
||||
onApprovalModeChanged(effectiveMode);
|
||||
} catch (e) {
|
||||
// Keep the dialog open and show the refusal: an empty catch here made a
|
||||
// gate rejection indistinguishable from an accepted choice.
|
||||
setError((e as Error).message);
|
||||
return;
|
||||
}
|
||||
settings.setValue(SettingScope.User, 'tools.approvalMode', mode);
|
||||
const effectiveMode = settings.merged.tools?.approvalMode ?? mode;
|
||||
config?.setApprovalMode?.(effectiveMode);
|
||||
onApprovalModeChanged(effectiveMode);
|
||||
} catch (e) {
|
||||
// Keep the dialog open and show the refusal: an empty catch here made a
|
||||
// gate rejection indistinguishable from an accepted choice.
|
||||
setError((e as Error).message);
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
const scopeItems: Array<LabeledItem<SettingScope>> = getScopeItems().map(
|
||||
(item) => ({
|
||||
key: item.value,
|
||||
value: item.value,
|
||||
label: t(item.label),
|
||||
}),
|
||||
);
|
||||
const scopeList = useDialogSelect<LabeledItem<SettingScope>>({
|
||||
items: scopeItems,
|
||||
initialIndex: Math.max(
|
||||
0,
|
||||
scopeItems.findIndex((item) => item.value === selectedScope),
|
||||
),
|
||||
focused: view === 'scope',
|
||||
numbers: view === 'scope',
|
||||
// ink's handleScopeSelect only records the scope and steps back: the mode
|
||||
// row's Enter is what persists.
|
||||
onSelect: (scope) => {
|
||||
setSelectedScope(scope);
|
||||
setView('mode');
|
||||
},
|
||||
onHighlight: (scope) => setSelectedScope(scope),
|
||||
});
|
||||
|
||||
useDialogFrameKeys({
|
||||
onTab: () => setView((prev) => (prev === 'mode' ? 'scope' : 'mode')),
|
||||
onEscape: onClose,
|
||||
});
|
||||
|
||||
const otherScopeModifiedMessage = getScopeMessageForSetting(
|
||||
'tools.approvalMode',
|
||||
selectedScope,
|
||||
settings,
|
||||
);
|
||||
const showWorkspacePriorityWarning =
|
||||
selectedScope === SettingScope.User &&
|
||||
otherScopeModifiedMessage.toLowerCase().includes('workspace');
|
||||
|
||||
return (
|
||||
<Shell title="Approval Mode">
|
||||
{error && (
|
||||
<box marginTop={1}>
|
||||
<text fg={C.red}>{error}</text>
|
||||
<DialogFrame>
|
||||
{view === 'mode' ? (
|
||||
<box flexDirection="column">
|
||||
<DialogTitle
|
||||
title={t('Approval Mode')}
|
||||
subtitle={otherScopeModifiedMessage}
|
||||
/>
|
||||
<LabeledRows list={modeList} focused={view === 'mode'} />
|
||||
{showWorkspacePriorityWarning ? (
|
||||
<box marginTop={1}>
|
||||
<text fg={C.yellow}>
|
||||
{`⚠ ${t(
|
||||
'Workspace approval mode exists and takes priority. User-level change will have no effect.',
|
||||
)}`}
|
||||
</text>
|
||||
</box>
|
||||
) : null}
|
||||
{error ? (
|
||||
<box marginTop={1}>
|
||||
<text fg={C.red}>{error}</text>
|
||||
</box>
|
||||
) : null}
|
||||
</box>
|
||||
) : (
|
||||
<box flexDirection="column">
|
||||
<DialogTitle title={t('Apply To')} />
|
||||
<LabeledRows list={scopeList} focused={view === 'scope'} />
|
||||
</box>
|
||||
)}
|
||||
<RadioList
|
||||
items={modes.map((m) => ({
|
||||
key: m,
|
||||
label: String(m),
|
||||
desc: MODE_DESC[String(m)],
|
||||
}))}
|
||||
selected={sel}
|
||||
onMove={(d) =>
|
||||
setSel((s) => Math.min(modes.length - 1, Math.max(0, s + d)))
|
||||
<FooterHint
|
||||
text={
|
||||
view === 'mode'
|
||||
? t('(Use Enter to select, Tab to configure scope)')
|
||||
: t('(Use Enter to apply scope, Tab to go back)')
|
||||
}
|
||||
onPick={pick}
|
||||
/>
|
||||
</Shell>
|
||||
</DialogFrame>
|
||||
);
|
||||
}
|
||||
|
||||
const EFFORT_DESC: Record<string, string> = {
|
||||
low: 'Fastest and cheapest',
|
||||
medium: 'Balanced speed/cost',
|
||||
high: 'Default strong reasoning',
|
||||
xhigh: 'Extended agentic reasoning',
|
||||
max: 'Maximum reasoning',
|
||||
};
|
||||
|
||||
export function OpenTuiEffortDialog(props: {
|
||||
config?: Config;
|
||||
settings: LoadedSettings;
|
||||
onClose: () => void;
|
||||
notify?: (text: string, level?: 'info' | 'error') => void;
|
||||
}) {
|
||||
const { config, settings, onClose } = props;
|
||||
const { config, settings, onClose, notify } = props;
|
||||
const tiers = config
|
||||
? [...getReasoningEffortsForConfig(config)]
|
||||
: (REASONING_EFFORT_TIERS as ReasoningEffort[]);
|
||||
@@ -215,11 +262,15 @@ export function OpenTuiEffortDialog(props: {
|
||||
// out-of-range effort starts at the top (ink EffortDialog parity).
|
||||
const currentEffort = config?.getReasoningEffort?.();
|
||||
const configuredIndex = currentEffort ? tiers.indexOf(currentEffort) : -1;
|
||||
const [sel, setSel] = useState(Math.max(0, configuredIndex));
|
||||
useEsc(onClose);
|
||||
const pick = () => {
|
||||
const effort = tiers[sel];
|
||||
if (effort) {
|
||||
const items: Array<LabeledItem<ReasoningEffort>> = tiers.map((tier) => ({
|
||||
key: tier,
|
||||
value: tier,
|
||||
label: `${tier} — ${t(EFFORT_DESCRIPTIONS[tier])}`,
|
||||
}));
|
||||
const list = useDialogSelect<LabeledItem<ReasoningEffort>>({
|
||||
items,
|
||||
initialIndex: Math.max(0, configuredIndex),
|
||||
onSelect: (effort) => {
|
||||
try {
|
||||
// Apply at runtime (next turn) and persist for future sessions;
|
||||
// provider adapters clamp the tier per model (ink useEffortCommand
|
||||
@@ -232,37 +283,41 @@ export function OpenTuiEffortDialog(props: {
|
||||
'model.reasoningEffort',
|
||||
effort,
|
||||
);
|
||||
// Read back after the apply: the message names what the provider
|
||||
// actually clamped the tier to, not what the row asked for.
|
||||
if (config) notify?.(formatEffortChangeMessage(config, effort));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
useDialogFrameKeys({ onEscape: onClose });
|
||||
|
||||
return (
|
||||
<Shell title="Reasoning Effort">
|
||||
<RadioList
|
||||
items={tiers.map((t) => ({
|
||||
key: t,
|
||||
label: String(t),
|
||||
desc: EFFORT_DESC[String(t)],
|
||||
}))}
|
||||
selected={sel}
|
||||
onMove={(d) =>
|
||||
setSel((s) => Math.min(tiers.length - 1, Math.max(0, s + d)))
|
||||
}
|
||||
onPick={pick}
|
||||
<DialogFrame>
|
||||
<DialogTitle
|
||||
title={t('Reasoning Effort')}
|
||||
subtitle={t('(applied across all providers; clamped per model)')}
|
||||
/>
|
||||
{currentEffort && configuredIndex === -1 ? (
|
||||
<text fg={C.dim}>
|
||||
{`${currentEffort} is not available for this model — using the model/provider default.`}
|
||||
</text>
|
||||
<LabeledRows list={list} focused />
|
||||
{configuredIndex === -1 ? (
|
||||
<box marginTop={1}>
|
||||
<text fg={C.dim}>
|
||||
{currentEffort
|
||||
? t(
|
||||
'{{effort}} is not available for this model — using the model/provider default.',
|
||||
{ effort: currentEffort },
|
||||
)
|
||||
: t('No effort configured — using the model/provider default.')}
|
||||
</text>
|
||||
</box>
|
||||
) : null}
|
||||
</Shell>
|
||||
<FooterHint text={t('(Use Enter to select, Esc to cancel)')} />
|
||||
</DialogFrame>
|
||||
);
|
||||
}
|
||||
|
||||
const DEFAULT_STYLE_DESC = 'The standard prompt, with no extra style';
|
||||
|
||||
/** Case-insensitive membership, the way the catalog dedupes and looks up. */
|
||||
function containsStyle(
|
||||
styles: readonly OutputStyleDefinition[],
|
||||
@@ -272,11 +327,19 @@ function containsStyle(
|
||||
return styles.some((style) => style.name.toLowerCase() === wanted);
|
||||
}
|
||||
|
||||
/** ink OutputStyleDialog's `describe`: built-ins translate, customs cite the source. */
|
||||
function describeStyle(style: OutputStyleDefinition): string {
|
||||
if (style.source === 'built-in') {
|
||||
return t(style.description);
|
||||
}
|
||||
return `${style.description} (${style.source})`;
|
||||
}
|
||||
|
||||
export function OpenTuiOutputStyleDialog(props: {
|
||||
config: Config;
|
||||
settings: LoadedSettings;
|
||||
onClose: () => void;
|
||||
notify: (text: string) => void;
|
||||
notify: (text: string, level?: 'info' | 'error') => void;
|
||||
}) {
|
||||
const { config, settings, onClose, notify } = props;
|
||||
// The catalog, not just the built-ins: a custom style can be active under
|
||||
@@ -306,6 +369,7 @@ export function OpenTuiOutputStyleDialog(props: {
|
||||
if (!cancelled) {
|
||||
notifyRef.current(
|
||||
`Failed to load output styles: ${error instanceof Error ? error.message : String(error)}`,
|
||||
'error',
|
||||
);
|
||||
onCloseRef.current();
|
||||
}
|
||||
@@ -316,11 +380,7 @@ export function OpenTuiOutputStyleDialog(props: {
|
||||
};
|
||||
}, [config]);
|
||||
|
||||
// Unlike /effort, "no style configured" genuinely is the first entry
|
||||
// (default), so pre-selecting index 0 in that case tells the truth (ink
|
||||
// OutputStyleDialog parity).
|
||||
const currentStyle = config.getOutputStyle();
|
||||
const current = currentStyle?.name;
|
||||
// The catalog is re-read on every open and skips a file it cannot parse, so
|
||||
// the active style can be absent from it (edited into an invalid state,
|
||||
// renamed, grown past the size cap, a dangling dotfiles symlink) while the
|
||||
@@ -332,69 +392,61 @@ export function OpenTuiOutputStyleDialog(props: {
|
||||
? [...styles, currentStyle]
|
||||
: styles;
|
||||
|
||||
const items: Array<{
|
||||
key: string;
|
||||
label: string;
|
||||
desc: string;
|
||||
style: OutputStyleDefinition | undefined;
|
||||
}> = catalog
|
||||
const items: Array<LabeledItem<OutputStyleDefinition | undefined>> = catalog
|
||||
? [
|
||||
{
|
||||
key: 'default',
|
||||
label: 'default',
|
||||
desc: DEFAULT_STYLE_DESC,
|
||||
style: undefined,
|
||||
value: undefined,
|
||||
label: `default — ${t('The standard prompt, with no extra style')}`,
|
||||
},
|
||||
...catalog.map((style) => ({
|
||||
key: style.name,
|
||||
label: style.name,
|
||||
desc:
|
||||
style.source === 'built-in'
|
||||
? style.description
|
||||
: `${style.description} (${style.source})`,
|
||||
style,
|
||||
value: style as OutputStyleDefinition | undefined,
|
||||
label: `${style.name} — ${describeStyle(style)}`,
|
||||
})),
|
||||
]
|
||||
: [];
|
||||
// Derive the selection after the catalog is ready. The catalog dedupes and
|
||||
// `findOutputStyle` looks up case-insensitively, so membership is matched
|
||||
// the same way here.
|
||||
const [sel, setSel] = useState(0);
|
||||
useEffect(() => {
|
||||
if (!styles) return;
|
||||
const wanted = current?.toLowerCase();
|
||||
const index = items.findIndex((item) => item.key.toLowerCase() === wanted);
|
||||
setSel(index >= 0 ? index : 0);
|
||||
// The item list is derived from `styles`, so that is the real dependency.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [styles, current]);
|
||||
useEsc(onClose);
|
||||
const pick = () => {
|
||||
const item = items[sel];
|
||||
if (!item) return;
|
||||
// Close first, like ink's handleOutputStyleSelect: the apply rebuilds
|
||||
// the system instruction, and the dialog should not sit open for it.
|
||||
onClose();
|
||||
void applyOutputStyleSelection(config, settings, item.style).then(
|
||||
(message) => notify(message),
|
||||
(error: unknown) =>
|
||||
notify(error instanceof Error ? error.message : String(error)),
|
||||
);
|
||||
};
|
||||
// Unlike /effort, "no style configured" genuinely is the first entry
|
||||
// (default), so pre-selecting index 0 in that case tells the truth. The name
|
||||
// is matched case-insensitively, like every other style lookup.
|
||||
const wanted = currentStyle?.name.toLowerCase();
|
||||
const list = useDialogSelect<LabeledItem<OutputStyleDefinition | undefined>>({
|
||||
items,
|
||||
initialIndex: Math.max(
|
||||
0,
|
||||
items.findIndex((item) => item.key.toLowerCase() === wanted),
|
||||
),
|
||||
onSelect: (style) => {
|
||||
// Close first, like ink's handleOutputStyleSelect: the apply rebuilds
|
||||
// the system instruction, and the dialog should not sit open for it.
|
||||
onClose();
|
||||
void applyOutputStyleSelection(config, settings, style).then(
|
||||
(message) => notify(message),
|
||||
(error: unknown) =>
|
||||
notify(
|
||||
t('Failed to set "{{key}}": {{error}}', {
|
||||
key: 'general.outputStyle',
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}),
|
||||
'error',
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
useDialogFrameKeys({ onEscape: onClose });
|
||||
|
||||
return (
|
||||
<Shell title="Output Style">
|
||||
{styles ? (
|
||||
<RadioList
|
||||
items={items}
|
||||
selected={sel}
|
||||
onMove={(d) =>
|
||||
setSel((s) => Math.min(items.length - 1, Math.max(0, s + d)))
|
||||
}
|
||||
onPick={pick}
|
||||
/>
|
||||
<DialogFrame>
|
||||
<DialogTitle
|
||||
title={t('Output Style')}
|
||||
subtitle={t('(applies now and persists to settings)')}
|
||||
/>
|
||||
{catalog ? (
|
||||
<LabeledRows list={list} focused />
|
||||
) : (
|
||||
<text fg={C.dim}>Loading output styles…</text>
|
||||
<text fg={C.dim}>{t('Loading output styles…')}</text>
|
||||
)}
|
||||
</Shell>
|
||||
<FooterHint text={t('(Use Enter to select, Esc to cancel)')} />
|
||||
</DialogFrame>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
/** @jsxImportSource @opentui/react */
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Burst safety of the two free-text fields in `/permissions`. The renderer
|
||||
* hands every key of one stdin read to the handler the last render registered,
|
||||
* so a pasted path followed by Enter used to submit the buffer as it stood
|
||||
* before the paste — i.e. nothing — and the dialog stayed put.
|
||||
*/
|
||||
|
||||
import { act, render, screen } from '@testing-library/react';
|
||||
import * as fs from 'node:fs';
|
||||
import * as nodePath from 'node:path';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { SettingScope } from '../../config/settings.js';
|
||||
|
||||
interface RawKey {
|
||||
name?: string;
|
||||
sequence?: string;
|
||||
ctrl?: boolean;
|
||||
meta?: boolean;
|
||||
option?: boolean;
|
||||
super?: boolean;
|
||||
shift?: boolean;
|
||||
paste?: boolean;
|
||||
}
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const state = {
|
||||
keyboardHandlers: [] as Array<(key: RawKey) => void>,
|
||||
width: 100,
|
||||
};
|
||||
async function buildJsxRuntime() {
|
||||
const React = await import('react');
|
||||
const jsx = (
|
||||
type: unknown,
|
||||
props: { children?: unknown } | null,
|
||||
key?: React.Key,
|
||||
) => {
|
||||
const config = key === undefined ? props : { ...props, key };
|
||||
const children = (config?.children ?? null) as React.ReactNode;
|
||||
if (type === 'box' || type === 'text') {
|
||||
return React.createElement(
|
||||
type === 'box' ? 'div' : 'span',
|
||||
key === undefined ? null : { key },
|
||||
children,
|
||||
);
|
||||
}
|
||||
return React.createElement(
|
||||
type as React.ElementType,
|
||||
config as Record<string, unknown>,
|
||||
children,
|
||||
);
|
||||
};
|
||||
return { jsx, jsxs: jsx, jsxDEV: jsx, Fragment: React.Fragment };
|
||||
}
|
||||
return { state, buildJsxRuntime };
|
||||
});
|
||||
|
||||
vi.mock('@opentui/react', async () => {
|
||||
const React = await import('react');
|
||||
return {
|
||||
useKeyboard: (handler: (key: RawKey) => void) => {
|
||||
const latest = React.useRef(handler);
|
||||
latest.current = handler;
|
||||
const stable = React.useRef<((key: RawKey) => void) | undefined>(
|
||||
undefined,
|
||||
);
|
||||
if (!stable.current) {
|
||||
stable.current = (key: RawKey) => latest.current(key);
|
||||
mocks.state.keyboardHandlers.push(stable.current);
|
||||
}
|
||||
},
|
||||
useTerminalDimensions: () => ({ width: mocks.state.width, height: 40 }),
|
||||
};
|
||||
});
|
||||
vi.mock('@opentui/core', () => ({
|
||||
SyntaxStyle: { fromStyles: () => ({}) },
|
||||
MouseButton: { LEFT: 0 },
|
||||
}));
|
||||
vi.mock('@opentui/react/jsx-runtime', () => mocks.buildJsxRuntime());
|
||||
vi.mock('@opentui/react/jsx-dev-runtime', () => mocks.buildJsxRuntime());
|
||||
vi.mock('./key-map.js', () => ({
|
||||
toOriginalKey: (key: RawKey) => ({
|
||||
name: key.name ?? '',
|
||||
ctrl: !!key.ctrl,
|
||||
meta: !!(key.meta || key.option || key.super),
|
||||
shift: !!key.shift,
|
||||
paste: !!key.paste,
|
||||
sequence: key.sequence ?? '',
|
||||
}),
|
||||
}));
|
||||
|
||||
import { OpenTuiPermissionsDialog } from './dialogs-permissions.js';
|
||||
|
||||
/** One stdin read: every key hits the same handler closure, no render between. */
|
||||
function burst(keys: RawKey[]) {
|
||||
if (mocks.state.keyboardHandlers.length === 0) {
|
||||
throw new Error('no keyboard handler registered');
|
||||
}
|
||||
act(() => {
|
||||
for (const key of keys) {
|
||||
for (const handler of [...mocks.state.keyboardHandlers]) {
|
||||
handler({ ...key });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** One key per stdin read, which is what a slow typist produces. */
|
||||
function press(key: RawKey) {
|
||||
burst([key]);
|
||||
}
|
||||
|
||||
function text(char: string): RawKey {
|
||||
return { name: char, sequence: char };
|
||||
}
|
||||
|
||||
const ENTER: RawKey = { name: 'return', sequence: '\r' };
|
||||
const TAB: RawKey = { name: 'tab', sequence: '\t' };
|
||||
|
||||
function renderDialog() {
|
||||
const onAddRule = vi.fn();
|
||||
const onAddDirectory = vi.fn();
|
||||
render(
|
||||
<OpenTuiPermissionsDialog
|
||||
rules={[]}
|
||||
directories={[]}
|
||||
initialDirectories={[]}
|
||||
onAddRule={onAddRule}
|
||||
onDeleteRule={vi.fn()}
|
||||
onAddDirectory={onAddDirectory}
|
||||
onRemoveDirectory={vi.fn()}
|
||||
onExit={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
return { onAddRule, onAddDirectory };
|
||||
}
|
||||
|
||||
/** Open the rule form on the Allow tab: 'Add a new rule…' is the first row. */
|
||||
function openRuleInput() {
|
||||
press(ENTER);
|
||||
expect(screen.getByText('Enter permission rule…')).toBeTruthy();
|
||||
}
|
||||
|
||||
/** Open the directory form: Workspace is the fourth tab, its first row adds. */
|
||||
function openDirInput() {
|
||||
press(TAB);
|
||||
press(TAB);
|
||||
press(TAB);
|
||||
press(ENTER);
|
||||
expect(screen.getByText('Enter directory path…')).toBeTruthy();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.state.keyboardHandlers.length = 0;
|
||||
mocks.state.width = 100;
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
describe('OpenTuiPermissionsDialog text-field bursts', () => {
|
||||
it('submits the rule a burst typed in, not the empty buffer it started from', () => {
|
||||
const { onAddRule } = renderDialog();
|
||||
openRuleInput();
|
||||
|
||||
burst([...'WebFetch'.split('').map(text), ENTER]);
|
||||
|
||||
expect(screen.queryByText('Enter permission rule…')).toBeNull();
|
||||
expect(screen.getByText('Where should this rule be saved?')).toBeTruthy();
|
||||
// The scope step then reports the whole burst as the rule text.
|
||||
press(ENTER);
|
||||
expect(onAddRule).toHaveBeenCalledWith(
|
||||
'WebFetch',
|
||||
'allow',
|
||||
SettingScope.Workspace,
|
||||
);
|
||||
});
|
||||
|
||||
it('edits inside a burst: a trailing character and its backspace cancel out', () => {
|
||||
const { onAddRule } = renderDialog();
|
||||
openRuleInput();
|
||||
|
||||
burst([...'WebFetchx'.split('').map(text), { name: 'backspace' }, ENTER]);
|
||||
|
||||
expect(screen.getByText('Where should this rule be saved?')).toBeTruthy();
|
||||
press(ENTER);
|
||||
expect(onAddRule).toHaveBeenCalledWith(
|
||||
'WebFetch',
|
||||
'allow',
|
||||
SettingScope.Workspace,
|
||||
);
|
||||
});
|
||||
|
||||
it('validates the whole pasted path, not the empty buffer', () => {
|
||||
const { onAddDirectory } = renderDialog();
|
||||
openDirInput();
|
||||
|
||||
burst([text('.'), ENTER]);
|
||||
|
||||
expect(onAddDirectory).toHaveBeenCalledTimes(1);
|
||||
expect(onAddDirectory).toHaveBeenCalledWith(
|
||||
fs.realpathSync(nodePath.resolve('.')),
|
||||
);
|
||||
expect(screen.queryByText('Enter directory path…')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -28,6 +28,7 @@ import { SettingScope } from '../../config/settings.js';
|
||||
import { parseRule } from '@qwen-code/qwen-code-core/permissions/rule-parser.js';
|
||||
import { isPathWithinRoot } from '@qwen-code/qwen-code-core/utils/workspaceContext.js';
|
||||
import { toOriginalKey } from './key-map.js';
|
||||
import { useBatchSafeCursor, useBatchSafeState } from './batch-cursor.js';
|
||||
import { matchesSearchQuery } from './dialogs-core.js';
|
||||
import {
|
||||
DialogFrame,
|
||||
@@ -211,17 +212,31 @@ export function OpenTuiPermissionsDialog(props: OpenTuiPermissionsDialogProps) {
|
||||
} = props;
|
||||
|
||||
const tabs = getPermissionsTabs();
|
||||
const [activeTabIndex, setActiveTabIndex] = useState(0);
|
||||
const {
|
||||
cursor: activeTabIndex,
|
||||
cursorRef: tabIndexRef,
|
||||
setCursor: setActiveTabIndex,
|
||||
} = useBatchSafeCursor();
|
||||
const activeTab = tabs[activeTabIndex]!;
|
||||
const [view, setView] = useState<PermissionsView>('rule-list');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [newRuleInput, setNewRuleInput] = useState('');
|
||||
// Enter lands in the same burst as the characters typed into the field, so
|
||||
// the handler submits the ref rather than the pre-burst render value.
|
||||
const {
|
||||
value: newRuleInput,
|
||||
ref: newRuleInputRef,
|
||||
setValue: setNewRuleInput,
|
||||
} = useBatchSafeState('');
|
||||
const [ruleInputError, setRuleInputError] = useState('');
|
||||
const [pendingRuleText, setPendingRuleText] = useState('');
|
||||
const [deleteTarget, setDeleteTarget] = useState<PermissionRuleEntry | null>(
|
||||
null,
|
||||
);
|
||||
const [newDirInput, setNewDirInput] = useState('');
|
||||
const {
|
||||
value: newDirInput,
|
||||
ref: newDirInputRef,
|
||||
setValue: setNewDirInput,
|
||||
} = useBatchSafeState('');
|
||||
const [dirInputError, setDirInputError] = useState('');
|
||||
const [removeDirTarget, setRemoveDirTarget] = useState<string | null>(null);
|
||||
|
||||
@@ -308,7 +323,8 @@ export function OpenTuiPermissionsDialog(props: OpenTuiPermissionsDialogProps) {
|
||||
});
|
||||
|
||||
const cycleTab = (direction: 1 | -1) => {
|
||||
const newIndex = (activeTabIndex + direction + tabs.length) % tabs.length;
|
||||
const newIndex =
|
||||
(tabIndexRef.current + direction + tabs.length) % tabs.length;
|
||||
setActiveTabIndex(newIndex);
|
||||
setSearchQuery('');
|
||||
const newTab = tabs[newIndex]!;
|
||||
@@ -354,7 +370,7 @@ export function OpenTuiPermissionsDialog(props: OpenTuiPermissionsDialogProps) {
|
||||
return;
|
||||
}
|
||||
if (name === 'return') {
|
||||
const trimmed = newRuleInput.trim();
|
||||
const trimmed = newRuleInputRef.current.trim();
|
||||
if (!trimmed) return;
|
||||
const rule = parseRule(trimmed);
|
||||
if (rule.invalid) {
|
||||
@@ -371,11 +387,11 @@ export function OpenTuiPermissionsDialog(props: OpenTuiPermissionsDialogProps) {
|
||||
return;
|
||||
}
|
||||
if (name === 'backspace') {
|
||||
setNewRuleInput((v) => v.slice(0, -1));
|
||||
setNewRuleInput(newRuleInputRef.current.slice(0, -1));
|
||||
return;
|
||||
}
|
||||
if (!ctrl && original.sequence.length === 1 && original.sequence >= ' ') {
|
||||
setNewRuleInput((v) => v + original.sequence);
|
||||
setNewRuleInput(newRuleInputRef.current + original.sequence);
|
||||
setRuleInputError('');
|
||||
}
|
||||
return;
|
||||
@@ -423,8 +439,11 @@ export function OpenTuiPermissionsDialog(props: OpenTuiPermissionsDialogProps) {
|
||||
// ink's handleAddDirSubmit returns early on empty input — the user
|
||||
// stays in the form instead of silently dropping back to the list
|
||||
// (validateWorkspaceDirectory's empty-input sentinel is falsy).
|
||||
if (!newDirInput.trim()) return;
|
||||
const result = validateWorkspaceDirectory(newDirInput, directories);
|
||||
if (!newDirInputRef.current.trim()) return;
|
||||
const result = validateWorkspaceDirectory(
|
||||
newDirInputRef.current,
|
||||
directories,
|
||||
);
|
||||
if (result.error) {
|
||||
setDirInputError(result.error);
|
||||
return;
|
||||
@@ -436,11 +455,11 @@ export function OpenTuiPermissionsDialog(props: OpenTuiPermissionsDialogProps) {
|
||||
return;
|
||||
}
|
||||
if (name === 'backspace') {
|
||||
setNewDirInput((v) => v.slice(0, -1));
|
||||
setNewDirInput(newDirInputRef.current.slice(0, -1));
|
||||
return;
|
||||
}
|
||||
if (!ctrl && original.sequence.length === 1 && original.sequence >= ' ') {
|
||||
setNewDirInput((v) => v + original.sequence);
|
||||
setNewDirInput(newDirInputRef.current + original.sequence);
|
||||
if (dirInputError) setDirInputError('');
|
||||
}
|
||||
return;
|
||||
|
||||
@@ -58,6 +58,7 @@ import { getSystemInfoFields } from '../systemInfoFields.js';
|
||||
import { ICON } from '../constants.js';
|
||||
import { keyMatchers, Command } from '../keyMatchers.js';
|
||||
import { toOriginalKey } from './key-map.js';
|
||||
import { useBatchSafeCursor } from './batch-cursor.js';
|
||||
import {
|
||||
DialogFrame,
|
||||
DialogSelect,
|
||||
@@ -261,7 +262,11 @@ export function OpenTuiSettingsDialog(props: OpenTuiSettingsDialogProps) {
|
||||
const [selectedScope, setSelectedScope] = useState<SettingScope>(
|
||||
SettingScope.User,
|
||||
);
|
||||
const [activeSettingIndex, setActiveSettingIndex] = useState(0);
|
||||
const {
|
||||
cursor: activeSettingIndex,
|
||||
cursorRef: activeSettingIndexRef,
|
||||
setCursor: setActiveSettingIndex,
|
||||
} = useBatchSafeCursor();
|
||||
const [scrollOffset, setScrollOffset] = useState(0);
|
||||
const [activeTab, setActiveTab] = useState<SettingsTab>('settings');
|
||||
const [focusZone, setFocusZone] = useState<'tabs' | 'search' | 'list'>(
|
||||
@@ -320,7 +325,7 @@ export function OpenTuiSettingsDialog(props: OpenTuiSettingsDialogProps) {
|
||||
useEffect(() => {
|
||||
setActiveSettingIndex(0);
|
||||
setScrollOffset(0);
|
||||
}, [searchQuery]);
|
||||
}, [searchQuery, setActiveSettingIndex]);
|
||||
|
||||
const allItems = buildSettingsListItems({
|
||||
excludeWorkspaceRestricted: selectedScope === SettingScope.Workspace,
|
||||
@@ -398,7 +403,7 @@ export function OpenTuiSettingsDialog(props: OpenTuiSettingsDialogProps) {
|
||||
};
|
||||
|
||||
const resetCurrentToDefault = (key: string) => {
|
||||
const currentSetting = items[activeSettingIndex];
|
||||
const currentSetting = items[activeSettingIndexRef.current];
|
||||
if (!currentSetting || currentSetting.key !== key) return;
|
||||
const defaultValue = getDefaultValue(key);
|
||||
applySettingValue(key, defaultValue);
|
||||
@@ -576,23 +581,25 @@ export function OpenTuiSettingsDialog(props: OpenTuiSettingsDialogProps) {
|
||||
return;
|
||||
}
|
||||
if (keyMatchers[Command.SELECTION_UP](original)) {
|
||||
if (activeSettingIndex === 0) {
|
||||
if (activeSettingIndexRef.current === 0) {
|
||||
setFocusZone('search');
|
||||
setScrollOffset(0);
|
||||
} else {
|
||||
const newIndex = activeSettingIndex - 1;
|
||||
const newIndex = activeSettingIndexRef.current - 1;
|
||||
setActiveSettingIndex(newIndex);
|
||||
if (newIndex < scrollOffset) setScrollOffset(newIndex);
|
||||
}
|
||||
} else if (keyMatchers[Command.SELECTION_DOWN](original)) {
|
||||
const newIndex =
|
||||
activeSettingIndex < items.length - 1 ? activeSettingIndex + 1 : 0;
|
||||
activeSettingIndexRef.current < items.length - 1
|
||||
? activeSettingIndexRef.current + 1
|
||||
: 0;
|
||||
setActiveSettingIndex(newIndex);
|
||||
if (newIndex === 0) setScrollOffset(0);
|
||||
else if (newIndex >= scrollOffset + maxItemsToShow)
|
||||
setScrollOffset(newIndex - maxItemsToShow + 1);
|
||||
} else if (name === 'return' || name === 'space') {
|
||||
const currentItem = items[activeSettingIndex];
|
||||
const currentItem = items[activeSettingIndexRef.current];
|
||||
if (!currentItem) return;
|
||||
if (isSubDialogSetting(currentItem.key)) {
|
||||
if (name === 'return') onSelect(currentItem.key, selectedScope);
|
||||
@@ -607,12 +614,12 @@ export function OpenTuiSettingsDialog(props: OpenTuiSettingsDialogProps) {
|
||||
toggleCurrent(currentItem.key);
|
||||
}
|
||||
} else if (name === 'right') {
|
||||
const currentItem = items[activeSettingIndex];
|
||||
const currentItem = items[activeSettingIndexRef.current];
|
||||
if (currentItem && isSubDialogSetting(currentItem.key)) {
|
||||
onSelect(currentItem.key, selectedScope);
|
||||
}
|
||||
} else if (/^[0-9]$/.test(original.sequence)) {
|
||||
const currentItem = items[activeSettingIndex];
|
||||
const currentItem = items[activeSettingIndexRef.current];
|
||||
if (isNumericSettingType(currentItem?.type)) {
|
||||
startEditing(currentItem.key, original.sequence);
|
||||
} else {
|
||||
@@ -620,7 +627,7 @@ export function OpenTuiSettingsDialog(props: OpenTuiSettingsDialogProps) {
|
||||
setSearchQuery((q) => q + original.sequence);
|
||||
}
|
||||
} else if (ctrl && (name === 'c' || name === 'l')) {
|
||||
const currentItem = items[activeSettingIndex];
|
||||
const currentItem = items[activeSettingIndexRef.current];
|
||||
if (currentItem) resetCurrentToDefault(currentItem.key);
|
||||
} else if (showRestartPrompt && name === 'r') {
|
||||
applyRestart();
|
||||
@@ -719,7 +726,8 @@ export function OpenTuiSettingsDialog(props: OpenTuiSettingsDialogProps) {
|
||||
onSelectIndex={scopeList.selectIndex}
|
||||
onWheel={(direction) =>
|
||||
scopeList.setActiveIndex(
|
||||
scopeList.activeIndex + (direction === 'down' ? 1 : -1),
|
||||
scopeList.activeIndexRef.current +
|
||||
(direction === 'down' ? 1 : -1),
|
||||
)
|
||||
}
|
||||
renderLabel={(item, { titleColor }) => (
|
||||
|
||||
@@ -74,6 +74,16 @@ const press = (key: { name: string; sequence?: string }) => {
|
||||
act(() => handler(key));
|
||||
};
|
||||
|
||||
/** One React batch: the renderer delivers a burst of keys to the handler
|
||||
* registered by the last render, with no re-render in between. */
|
||||
const pressBatched = (keys: Array<{ name: string; sequence?: string }>) => {
|
||||
const handler = handlers[handlers.length - 1];
|
||||
if (!handler) throw new Error('no keyboard handler registered');
|
||||
act(() => {
|
||||
for (const key of keys) handler(key);
|
||||
});
|
||||
};
|
||||
|
||||
describe('useDialogSelect numeric quick-select', () => {
|
||||
beforeEach(() => {
|
||||
handlers.length = 0;
|
||||
@@ -123,6 +133,32 @@ describe('useDialogSelect numeric quick-select', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('useDialogSelect cursor within one key batch', () => {
|
||||
beforeEach(() => {
|
||||
handlers.length = 0;
|
||||
});
|
||||
|
||||
it('moves the highlight once per arrow key in a single batch', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDialogSelect({ items, numbers: false }),
|
||||
);
|
||||
pressBatched([{ name: 'down' }, { name: 'down' }, { name: 'down' }]);
|
||||
expect(result.current.activeIndex).toBe(3);
|
||||
});
|
||||
|
||||
it('selects the row the batch arrows reached, not the pre-batch one', () => {
|
||||
const onSelect = vi.fn();
|
||||
renderHook(() => useDialogSelect({ items, numbers: false, onSelect }));
|
||||
pressBatched([
|
||||
{ name: 'down' },
|
||||
{ name: 'down' },
|
||||
{ name: 'return', sequence: '\r' },
|
||||
]);
|
||||
expect(onSelect).toHaveBeenCalledTimes(1);
|
||||
expect(onSelect).toHaveBeenCalledWith('item-2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('useDialogSelect setActiveIndex (ink SET_ACTIVE_INDEX parity)', () => {
|
||||
beforeEach(() => {
|
||||
handlers.length = 0;
|
||||
|
||||
@@ -21,6 +21,7 @@ import { useEffect, useRef, useState, type ReactNode } from 'react';
|
||||
import { MouseButton } from '@opentui/core';
|
||||
import { useKeyboard } from '@opentui/react';
|
||||
import { C } from './theme.js';
|
||||
import { useBatchSafeCursor } from './batch-cursor.js';
|
||||
import { keyMatchers, Command } from '../keyMatchers.js';
|
||||
import { toOriginalKey } from './key-map.js';
|
||||
import {
|
||||
@@ -86,7 +87,7 @@ export function DialogFrame(props: {
|
||||
<box
|
||||
flexDirection="column"
|
||||
borderStyle="rounded"
|
||||
borderColor={props.borderColor ?? C.dim}
|
||||
borderColor={props.borderColor ?? C.borderDefault}
|
||||
padding={1}
|
||||
>
|
||||
{props.children}
|
||||
@@ -160,6 +161,8 @@ export interface UseDialogSelectOptions<TItem extends DialogListItem<unknown>> {
|
||||
|
||||
export interface UseDialogSelectResult<TItem extends DialogListItem<unknown>> {
|
||||
activeIndex: number;
|
||||
/** The highlight as the current key/wheel burst sees it. */
|
||||
activeIndexRef: Readonly<{ current: number }>;
|
||||
scrollOffset: number;
|
||||
setScrollOffset: (offset: number) => void;
|
||||
setActiveIndex: (index: number) => void;
|
||||
@@ -190,9 +193,11 @@ export function useDialogSelect<TItem extends DialogListItem<unknown>>(
|
||||
onHighlight,
|
||||
} = options;
|
||||
|
||||
const [activeIndex, setActiveIndexState] = useState(() =>
|
||||
computeInitialActiveIndex(initialIndex, items),
|
||||
);
|
||||
const {
|
||||
cursor: activeIndex,
|
||||
cursorRef,
|
||||
setCursor: moveCursor,
|
||||
} = useBatchSafeCursor(() => computeInitialActiveIndex(initialIndex, items));
|
||||
const [scrollOffset, setScrollOffset] = useState(() =>
|
||||
getSelectionScrollOffset(
|
||||
computeInitialActiveIndex(initialIndex, items),
|
||||
@@ -226,7 +231,7 @@ export function useDialogSelect<TItem extends DialogListItem<unknown>>(
|
||||
// key-follow below must not override it with the previous view's key.
|
||||
itemsRef.current = items;
|
||||
const next = computeInitialActiveIndex(initialIndex, items);
|
||||
setActiveIndexState(next);
|
||||
moveCursor(next);
|
||||
setScrollOffset(
|
||||
getSelectionScrollOffset(next, items.length, maxItemsToShow),
|
||||
);
|
||||
@@ -257,7 +262,7 @@ export function useDialogSelect<TItem extends DialogListItem<unknown>>(
|
||||
followed >= 0
|
||||
? followed
|
||||
: computeInitialActiveIndex(initialIndex, items);
|
||||
setActiveIndexState(next);
|
||||
moveCursor(next);
|
||||
setScrollOffset(
|
||||
getSelectionScrollOffset(next, items.length, maxItemsToShow),
|
||||
);
|
||||
@@ -292,9 +297,11 @@ export function useDialogSelect<TItem extends DialogListItem<unknown>>(
|
||||
};
|
||||
|
||||
const highlightIndex = (index: number) => {
|
||||
if (index < 0 || index >= items.length || index === activeIndex) return;
|
||||
if (index < 0 || index >= items.length || index === cursorRef.current) {
|
||||
return;
|
||||
}
|
||||
if (items[index]?.disabled) return;
|
||||
setActiveIndexState(index);
|
||||
moveCursor(index);
|
||||
const item = items[index];
|
||||
if (item) onHighlight?.(item.value, index);
|
||||
};
|
||||
@@ -303,12 +310,14 @@ export function useDialogSelect<TItem extends DialogListItem<unknown>>(
|
||||
// like wheel/hover navigation step one row per gesture, and rejecting
|
||||
// disabled targets would leave them permanently stuck on a disabled row.
|
||||
const setActiveIndex = (index: number) => {
|
||||
if (index < 0 || index >= items.length || index === activeIndex) return;
|
||||
if (index < 0 || index >= items.length || index === cursorRef.current) {
|
||||
return;
|
||||
}
|
||||
// Moving the highlight by any means (wheel, hover) invalidates a
|
||||
// pending numeric flush: the flush must commit the typed row, not
|
||||
// wherever the pointer happened to land.
|
||||
clearNumberBuffer();
|
||||
setActiveIndexState(index);
|
||||
moveCursor(index);
|
||||
const item = items[index];
|
||||
if (item) onHighlight?.(item.value, index);
|
||||
};
|
||||
@@ -322,7 +331,7 @@ export function useDialogSelect<TItem extends DialogListItem<unknown>>(
|
||||
// ink dispatches SET_ACTIVE_INDEX before SELECT_CURRENT, so highlight
|
||||
// consumers (theme preview, scope selection) stay synced on mouse input
|
||||
// too, not just keyboard input.
|
||||
setActiveIndexState(index);
|
||||
moveCursor(index);
|
||||
onHighlight?.(item.value, index);
|
||||
onSelect?.(item.value);
|
||||
};
|
||||
@@ -346,13 +355,13 @@ export function useDialogSelect<TItem extends DialogListItem<unknown>>(
|
||||
);
|
||||
numberBuffer.current = result.buffer;
|
||||
if (result.activeIndex !== undefined) {
|
||||
setActiveIndexState(result.activeIndex);
|
||||
moveCursor(result.activeIndex);
|
||||
const item = items[result.activeIndex];
|
||||
if (item) onHighlight?.(item.value, result.activeIndex);
|
||||
}
|
||||
if (result.selectNow) {
|
||||
clearNumberBuffer();
|
||||
const item = items[result.activeIndex ?? activeIndex];
|
||||
const item = items[result.activeIndex ?? cursorRef.current];
|
||||
if (item && !item.disabled) onSelect?.(item.value);
|
||||
} else if (result.pendingSelect) {
|
||||
numberTimer.current = setTimeout(() => {
|
||||
@@ -372,21 +381,24 @@ export function useDialogSelect<TItem extends DialogListItem<unknown>>(
|
||||
clearNumberBuffer();
|
||||
|
||||
if (keyMatchers[Command.SELECTION_UP](original)) {
|
||||
highlightIndex(findNextEnabledIndex(items, activeIndex, 'up'));
|
||||
highlightIndex(findNextEnabledIndex(items, cursorRef.current, 'up'));
|
||||
return;
|
||||
}
|
||||
if (keyMatchers[Command.SELECTION_DOWN](original)) {
|
||||
highlightIndex(findNextEnabledIndex(items, activeIndex, 'down'));
|
||||
highlightIndex(findNextEnabledIndex(items, cursorRef.current, 'down'));
|
||||
return;
|
||||
}
|
||||
if (original.name === 'return') {
|
||||
const item = items[activeIndex];
|
||||
const item = items[cursorRef.current];
|
||||
if (item && !item.disabled) onSelect?.(item.value);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
activeIndex,
|
||||
// Handlers of a key or wheel burst read the live index, not the one this
|
||||
// render captured.
|
||||
activeIndexRef: cursorRef,
|
||||
scrollOffset,
|
||||
setScrollOffset,
|
||||
setActiveIndex,
|
||||
|
||||
@@ -14,7 +14,13 @@
|
||||
* same numbers/sections as the original. Tab/shift+tab switch, Esc closes.
|
||||
*/
|
||||
|
||||
import { useEffect, useLayoutEffect, useState, type ReactNode } from 'react';
|
||||
import {
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import { useRenderer, useKeyboard } from '@opentui/react';
|
||||
import type { Config } from '@qwen-code/qwen-code-core/config/config.js';
|
||||
import { uiTelemetryService } from '@qwen-code/qwen-code-core/telemetry/uiTelemetry.js';
|
||||
@@ -103,7 +109,15 @@ export function OpenTuiStatsDialog(props: {
|
||||
isFocused?: boolean;
|
||||
}) {
|
||||
const { config, onClose, isFocused = true } = props;
|
||||
const [tab, setTab] = useState<StatsTabName>('session');
|
||||
const [tab, setTabState] = useState<StatsTabName>('session');
|
||||
// A held Tab hands the whole burst to the handler from the last render, so
|
||||
// the cycle must read where the previous key of the burst landed.
|
||||
const tabRef = useRef(tab);
|
||||
tabRef.current = tab;
|
||||
const setTab = (next: StatsTabName) => {
|
||||
tabRef.current = next;
|
||||
setTabState(next);
|
||||
};
|
||||
// Re-render on every telemetry update so stats stay live while the dialog
|
||||
// is open (ink re-renders via SessionStatsProvider's update event).
|
||||
const [, forceUpdate] = useState(0);
|
||||
@@ -120,7 +134,7 @@ export function OpenTuiStatsDialog(props: {
|
||||
const original = toOriginalKey(key);
|
||||
if (original.name === 'tab') {
|
||||
const order = TABS.map((t) => t.name);
|
||||
const idx = order.indexOf(tab);
|
||||
const idx = order.indexOf(tabRef.current);
|
||||
setTab(
|
||||
order[(idx + (original.shift ? -1 : 1) + order.length) % order.length],
|
||||
);
|
||||
|
||||
@@ -249,7 +249,8 @@ export function OpenTuiThemeDialog(props: OpenTuiThemeDialogProps) {
|
||||
onSelectIndex={themeList.selectIndex}
|
||||
onWheel={(direction) =>
|
||||
themeList.setActiveIndex(
|
||||
themeList.activeIndex + (direction === 'down' ? 1 : -1),
|
||||
themeList.activeIndexRef.current +
|
||||
(direction === 'down' ? 1 : -1),
|
||||
)
|
||||
}
|
||||
renderLabel={(item, { titleColor }) => (
|
||||
@@ -311,7 +312,8 @@ export function OpenTuiThemeDialog(props: OpenTuiThemeDialogProps) {
|
||||
onSelectIndex={scopeList.selectIndex}
|
||||
onWheel={(direction) =>
|
||||
scopeList.setActiveIndex(
|
||||
scopeList.activeIndex + (direction === 'down' ? 1 : -1),
|
||||
scopeList.activeIndexRef.current +
|
||||
(direction === 'down' ? 1 : -1),
|
||||
)
|
||||
}
|
||||
renderLabel={(item, { titleColor }) => (
|
||||
|
||||
@@ -707,14 +707,24 @@ describe('event-adapter (ServerGeminiStreamEvent -> neutral)', () => {
|
||||
).toBe('S\nN');
|
||||
});
|
||||
|
||||
it('renders task_execution without dumping toolCalls payloads (R1-68)', () => {
|
||||
it('renders task_execution as ink one-line summary (R1-68)', () => {
|
||||
expect(
|
||||
renderResultDisplay({
|
||||
type: 'task_execution',
|
||||
subagentName: 'reviewer',
|
||||
taskDescription: 'check imports',
|
||||
status: 'completed',
|
||||
terminateReason: 'done',
|
||||
result: 'all good',
|
||||
executionSummary: {
|
||||
totalToolCalls: 5,
|
||||
totalDurationMs: 12_000,
|
||||
outputTokens: 2400,
|
||||
toolUsage: [
|
||||
{ name: 'read-file', success: 5, failure: 0 },
|
||||
{ name: 'task', success: 2, failure: 1 },
|
||||
],
|
||||
},
|
||||
toolCalls: [
|
||||
{
|
||||
callId: 'c1',
|
||||
@@ -724,7 +734,34 @@ describe('event-adapter (ServerGeminiStreamEvent -> neutral)', () => {
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toBe('reviewer: completed\ndone\nall good');
|
||||
).toBe(
|
||||
' ✔ reviewer: check imports · 5 tools · 2 sub-agents · 12s · 2.4k tokens',
|
||||
);
|
||||
});
|
||||
|
||||
it('renders nothing for a task_execution that is still running', () => {
|
||||
expect(
|
||||
renderResultDisplay({
|
||||
type: 'task_execution',
|
||||
subagentName: 'reviewer',
|
||||
taskDescription: 'check imports',
|
||||
status: 'running',
|
||||
result: 'partial answer',
|
||||
}),
|
||||
).toBe('');
|
||||
});
|
||||
|
||||
it('appends the terminate reason only when the subagent did not complete', () => {
|
||||
expect(
|
||||
renderResultDisplay({
|
||||
type: 'task_execution',
|
||||
subagentName: 'reviewer',
|
||||
taskDescription: 'check imports',
|
||||
status: 'failed',
|
||||
terminateReason: 'model error',
|
||||
executionSummary: { totalToolCalls: 1, totalDurationMs: 900 },
|
||||
}),
|
||||
).toBe(' ✖ reviewer: check imports · 1 tool · 900ms · model error');
|
||||
});
|
||||
|
||||
it('renders findings_list as a count summary (R1-68)', () => {
|
||||
@@ -826,6 +863,35 @@ describe('event-adapter (ServerGeminiStreamEvent -> neutral)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps a terminal subagent summary structured for its coloured runs', () => {
|
||||
expect(
|
||||
toolResultEvent('c1', {
|
||||
type: 'task_execution',
|
||||
subagentName: 'reviewer',
|
||||
taskDescription: 'check imports',
|
||||
status: 'cancelled',
|
||||
terminateReason: 'interrupted',
|
||||
executionSummary: { totalToolCalls: 2 },
|
||||
}),
|
||||
).toEqual({
|
||||
type: 'tool-result',
|
||||
id: 'c1',
|
||||
display: '',
|
||||
subagentSummary: {
|
||||
glyph: '✖',
|
||||
tone: 'warning',
|
||||
prefix: 'reviewer: ',
|
||||
rest: 'check imports · 2 tools · interrupted',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves a running subagent to the roster, structured or not', () => {
|
||||
expect(
|
||||
toolResultEvent('c1', { type: 'task_execution', status: 'running' }),
|
||||
).toBe(null);
|
||||
});
|
||||
|
||||
it('falls back to the flattened text, and to no event at all', () => {
|
||||
expect(toolResultEvent('c1', 'plain')).toEqual({
|
||||
type: 'tool-result',
|
||||
|
||||
@@ -32,6 +32,8 @@ import type { StreamEvent } from '../model/streaming-model.js';
|
||||
import type { TodoItem } from '../components/TodoDisplay.js';
|
||||
import type { ArenaAgentCardData, CompressionProps } from '../types.js';
|
||||
import { sanitizeSensitiveText } from '../utils/textUtils.js';
|
||||
import { AGENT_TOOL_NAMES } from '../utils/agent-tool-names.js';
|
||||
import { formatDuration, formatTokenCount } from '../utils/formatters.js';
|
||||
import { sanitizeDisplayText } from '../../utils/extension-mention.js';
|
||||
import { shouldDisplayGoalStateCause } from '../utils/goal-runtime.js';
|
||||
|
||||
@@ -41,7 +43,11 @@ import { shouldDisplayGoalStateCause } from '../utils/goal-runtime.js';
|
||||
* segmentation and inline images.
|
||||
*/
|
||||
export type OpenTuiStreamEvent =
|
||||
| StreamEvent
|
||||
| Exclude<StreamEvent, { type: 'text' }>
|
||||
/** Widened over the neutral `text` event: a replayed assistant record
|
||||
* carries its own recorded time, which ink stamps on the first display run
|
||||
* of that record. Live deltas leave it unset and fold as "now". */
|
||||
| { type: 'text'; delta: string; timestamp?: number }
|
||||
| { type: 'tool-args'; id: string; args: string }
|
||||
/** Real invocation description (live sessions only): the scheduler's
|
||||
* tracked call carries the invocation object, so the card title is the
|
||||
@@ -68,6 +74,9 @@ export type OpenTuiStreamEvent =
|
||||
totalLines?: number;
|
||||
totalBytes?: number;
|
||||
};
|
||||
/** Structured subagent summary: rendered as ink's three coloured runs
|
||||
* (SubagentScrollbackSummary parity) instead of the flattened text. */
|
||||
subagentSummary?: SubagentSummary;
|
||||
/** Vision-bridge egress disclosure (ink ToolMessage renders the notice
|
||||
* under the result): tells the user their image/prompt left the
|
||||
* machine via the vision model. */
|
||||
@@ -336,18 +345,13 @@ export function renderResultDisplay(display: unknown): string {
|
||||
const notice = typeof o['notice'] === 'string' ? o['notice'] : '';
|
||||
return [summary, notice].filter(Boolean).join('\n');
|
||||
}
|
||||
// task_execution: status line + termination reason and result — the raw
|
||||
// JSON fallback would dump toolCalls[].responseParts (multi-MB base64);
|
||||
// core strips those exact fields in toolResultDisplayCompaction.
|
||||
// task_execution: ink renders exactly one summary line once the subagent
|
||||
// reaches a terminal state, and nothing at all while it runs (the running
|
||||
// phase belongs to the bottom-of-screen roster). The raw `result` field is
|
||||
// the child's whole answer, so inlining it under the parent's tool card
|
||||
// dumps an entire sub-transcript into the parent's scrollback.
|
||||
if (o['type'] === 'task_execution') {
|
||||
const subagent =
|
||||
typeof o['subagentName'] === 'string' ? o['subagentName'] : '';
|
||||
const status = typeof o['status'] === 'string' ? o['status'] : '';
|
||||
const reason =
|
||||
typeof o['terminateReason'] === 'string' ? o['terminateReason'] : '';
|
||||
const result = typeof o['result'] === 'string' ? o['result'] : '';
|
||||
const header = [subagent, status].filter(Boolean).join(': ');
|
||||
return [header, reason, result].filter(Boolean).join('\n');
|
||||
return subagentSummaryLine(o);
|
||||
}
|
||||
// findings_list: count + optional severity summary; the raw fallback
|
||||
// dumps every finding object.
|
||||
@@ -374,6 +378,100 @@ export function renderResultDisplay(display: unknown): string {
|
||||
return JSON.stringify(display, null, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* ink's SubagentScrollbackSummary row as its three coloured runs: the status
|
||||
* glyph, the bold `name: ` prefix, and the secondary description/tail/reason.
|
||||
*/
|
||||
export interface SubagentSummary {
|
||||
glyph: string;
|
||||
/** ink's glyph colour: success / error / warning by terminal status. */
|
||||
tone: 'success' | 'error' | 'warning';
|
||||
prefix: string;
|
||||
rest: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One summary per subagent that has reached a terminal state, `null` while it
|
||||
* still runs.
|
||||
*/
|
||||
export function extractSubagentSummary(
|
||||
display: unknown,
|
||||
): SubagentSummary | null {
|
||||
if (typeof display !== 'object' || display === null) return null;
|
||||
const o = display as Record<string, unknown>;
|
||||
if (o['type'] !== 'task_execution') return null;
|
||||
const status = typeof o['status'] === 'string' ? o['status'] : '';
|
||||
if (status !== 'completed' && status !== 'failed' && status !== 'cancelled') {
|
||||
return null;
|
||||
}
|
||||
const summary =
|
||||
typeof o['executionSummary'] === 'object' && o['executionSummary'] !== null
|
||||
? (o['executionSummary'] as Record<string, unknown>)
|
||||
: {};
|
||||
const parts: string[] = [];
|
||||
const totalToolCalls = summary['totalToolCalls'];
|
||||
if (typeof totalToolCalls === 'number') {
|
||||
parts.push(`${totalToolCalls} tool${totalToolCalls === 1 ? '' : 's'}`);
|
||||
}
|
||||
// Direct children this agent spawned = its successful AgentTool calls.
|
||||
// Tool-usage stats key on the raw request name, so every alias counts.
|
||||
const toolUsage = Array.isArray(summary['toolUsage'])
|
||||
? (summary['toolUsage'] as Array<Record<string, unknown>>)
|
||||
: [];
|
||||
const spawns = toolUsage
|
||||
.filter(
|
||||
(tu) =>
|
||||
typeof tu['name'] === 'string' && AGENT_TOOL_NAMES.has(tu['name']),
|
||||
)
|
||||
.reduce(
|
||||
(sum, tu) =>
|
||||
sum + (typeof tu['success'] === 'number' ? tu['success'] : 0),
|
||||
0,
|
||||
);
|
||||
if (spawns > 0) {
|
||||
parts.push(`${spawns} sub-agent${spawns === 1 ? '' : 's'}`);
|
||||
}
|
||||
const totalDurationMs = summary['totalDurationMs'];
|
||||
if (typeof totalDurationMs === 'number') {
|
||||
parts.push(formatDuration(totalDurationMs, { hideTrailingZeros: true }));
|
||||
}
|
||||
const outputTokens = summary['outputTokens'];
|
||||
if (typeof outputTokens === 'number' && outputTokens > 0) {
|
||||
parts.push(`${formatTokenCount(outputTokens)} tokens`);
|
||||
}
|
||||
const tail = parts.length > 0 ? ` · ${parts.join(' · ')}` : '';
|
||||
const subagent =
|
||||
typeof o['subagentName'] === 'string' ? o['subagentName'] : '';
|
||||
const description =
|
||||
typeof o['taskDescription'] === 'string' ? o['taskDescription'] : '';
|
||||
const terminateReason =
|
||||
typeof o['terminateReason'] === 'string' ? o['terminateReason'] : '';
|
||||
const reason =
|
||||
status !== 'completed' && terminateReason ? ` · ${terminateReason}` : '';
|
||||
return {
|
||||
glyph: status === 'completed' ? '✔' : '✖',
|
||||
tone:
|
||||
status === 'completed'
|
||||
? 'success'
|
||||
: status === 'failed'
|
||||
? 'error'
|
||||
: 'warning',
|
||||
prefix: subagent ? `${subagent}: ` : '',
|
||||
rest: `${description}${tail}${reason}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The same row as plain text, for the consumers that keep the flattened
|
||||
* display. Derived from the segments so the two forms cannot drift.
|
||||
*/
|
||||
function subagentSummaryLine(o: Record<string, unknown>): string {
|
||||
const summary = extractSubagentSummary(o);
|
||||
// The leading space is ink's own: its summary sits in a box that adds one
|
||||
// column of padding on top of the card body's indent.
|
||||
return summary ? ` ${summary.glyph} ${summary.prefix}${summary.rest}` : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* The structured payload a result display carries, if any. The tool card has an
|
||||
* ink-parity renderer for each of a file diff, a todo list and an ANSI grid,
|
||||
@@ -387,7 +485,7 @@ export function extractStructuredResult(
|
||||
): Partial<
|
||||
Pick<
|
||||
Extract<OpenTuiStreamEvent, { type: 'tool-result' }>,
|
||||
'diff' | 'todos' | 'ansi'
|
||||
'diff' | 'todos' | 'ansi' | 'subagentSummary'
|
||||
>
|
||||
> | null {
|
||||
const diff = extractFileDiff(display);
|
||||
@@ -396,6 +494,8 @@ export function extractStructuredResult(
|
||||
if (todos) return { todos };
|
||||
const ansi = extractAnsiOutput(display);
|
||||
if (ansi) return { ansi };
|
||||
const subagentSummary = extractSubagentSummary(display);
|
||||
if (subagentSummary) return { subagentSummary };
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* The OpenTUI leg's terminal-focus flag (ink useFocus parity): it owns the
|
||||
* `?1004` mode switch the renderer never writes, follows the renderer's
|
||||
* focus/blur events, and re-asserts focus on a keypress the way ink's tmux
|
||||
* workaround does. Without the mode switch a terminal sends no focus events
|
||||
* at all, so the away recap could never fire.
|
||||
*/
|
||||
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
// vi.hoisted runs before the module's imports, so the stub cannot be a node
|
||||
// EventEmitter; a plain registry is also what lets the tests assert the
|
||||
// unmount removed its listeners.
|
||||
const listeners: Record<string, Array<() => void>> = {};
|
||||
return {
|
||||
listeners,
|
||||
renderer: {
|
||||
on(event: string, fn: () => void): void {
|
||||
(listeners[event] ??= []).push(fn);
|
||||
},
|
||||
off(event: string, fn: () => void): void {
|
||||
listeners[event] = (listeners[event] ?? []).filter(
|
||||
(listener) => listener !== fn,
|
||||
);
|
||||
},
|
||||
emit(event: string): void {
|
||||
for (const fn of [...(listeners[event] ?? [])]) fn();
|
||||
},
|
||||
},
|
||||
keyHandler: null as null | (() => void),
|
||||
written: [] as string[],
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@opentui/react', () => ({
|
||||
useRenderer: () => mocks.renderer,
|
||||
useKeyboard: (handler: () => void) => {
|
||||
mocks.keyHandler = handler;
|
||||
},
|
||||
}));
|
||||
vi.mock('@opentui/core', () => ({
|
||||
CliRenderEvents: { FOCUS: 'focus', BLUR: 'blur' },
|
||||
}));
|
||||
|
||||
import { useTerminalFocus } from './focus-tracking.js';
|
||||
|
||||
const write = vi.spyOn(process.stdout, 'write').mockImplementation((chunk) => {
|
||||
mocks.written.push(String(chunk));
|
||||
return true;
|
||||
});
|
||||
|
||||
function renderFocus() {
|
||||
return renderHook(() => useTerminalFocus());
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
for (const event of Object.keys(mocks.listeners))
|
||||
delete mocks.listeners[event];
|
||||
mocks.keyHandler = null;
|
||||
mocks.written.length = 0;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
write.mockClear();
|
||||
});
|
||||
|
||||
describe('useTerminalFocus', () => {
|
||||
it('switches focus reporting on for the mount and off for the unmount', () => {
|
||||
const { unmount } = renderFocus();
|
||||
expect(mocks.written).toEqual(['\x1b[?1004h']);
|
||||
unmount();
|
||||
expect(mocks.written).toEqual(['\x1b[?1004h', '\x1b[?1004l']);
|
||||
});
|
||||
|
||||
it('starts focused and follows the renderer focus/blur events', () => {
|
||||
const { result } = renderFocus();
|
||||
expect(result.current).toBe(true);
|
||||
act(() => mocks.renderer.emit('blur'));
|
||||
expect(result.current).toBe(false);
|
||||
act(() => mocks.renderer.emit('focus'));
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
|
||||
it('re-asserts focus on a keypress, as ink does for tmux', () => {
|
||||
const { result } = renderFocus();
|
||||
act(() => mocks.renderer.emit('blur'));
|
||||
expect(result.current).toBe(false);
|
||||
act(() => mocks.keyHandler?.());
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
|
||||
it('detaches from the renderer once unmounted', () => {
|
||||
const { unmount } = renderFocus();
|
||||
expect(mocks.listeners['blur']).toHaveLength(1);
|
||||
unmount();
|
||||
expect(mocks.listeners['blur']).toHaveLength(0);
|
||||
expect(mocks.listeners['focus']).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('survives a stdout that throws on the mode write', () => {
|
||||
write.mockImplementationOnce(() => {
|
||||
throw new Error('ERR_STREAM_DESTROYED');
|
||||
});
|
||||
const { result } = renderFocus();
|
||||
expect(result.current).toBe(true);
|
||||
act(() => mocks.renderer.emit('blur'));
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Terminal focus tracking for the OpenTUI leg (ink `useFocus` parity).
|
||||
*
|
||||
* @opentui/core already parses the focus events — its renderer consumes
|
||||
* `\x1b[I` / `\x1b[O` as an input handler and re-emits them as `focus` /
|
||||
* `blur` — but nothing switches focus reporting on, so a terminal never
|
||||
* sends them. This hook owns the `?1004` mode for the OpenTUI leg; ink's
|
||||
* `useFocus` writes the same escapes for the ink leg, and only one leg is
|
||||
* ever mounted.
|
||||
*
|
||||
* The keypress re-assertion is ink's tmux workaround: a session that does not
|
||||
* forward focus events would otherwise stay blurred forever, so any key means
|
||||
* the terminal is focused.
|
||||
*/
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useKeyboard, useRenderer } from '@opentui/react';
|
||||
import { CliRenderEvents } from '@opentui/core';
|
||||
|
||||
const ENABLE_FOCUS_REPORTING = '\x1b[?1004h';
|
||||
const DISABLE_FOCUS_REPORTING = '\x1b[?1004l';
|
||||
|
||||
export function useTerminalFocus(): boolean {
|
||||
const renderer = useRenderer();
|
||||
const [isFocused, setIsFocused] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const onFocus = (): void => setIsFocused(true);
|
||||
const onBlur = (): void => setIsFocused(false);
|
||||
renderer.on(CliRenderEvents.FOCUS, onFocus);
|
||||
renderer.on(CliRenderEvents.BLUR, onBlur);
|
||||
// A write can still throw with isTTY set (ERR_STREAM_DESTROYED during a
|
||||
// fast exit); losing focus events is the same degradation ink has.
|
||||
try {
|
||||
process.stdout.write(ENABLE_FOCUS_REPORTING);
|
||||
} catch {
|
||||
// No focus reporting on this stream.
|
||||
}
|
||||
return () => {
|
||||
renderer.off(CliRenderEvents.FOCUS, onFocus);
|
||||
renderer.off(CliRenderEvents.BLUR, onBlur);
|
||||
try {
|
||||
process.stdout.write(DISABLE_FOCUS_REPORTING);
|
||||
} catch {
|
||||
// Nothing left to restore.
|
||||
}
|
||||
};
|
||||
}, [renderer]);
|
||||
|
||||
useKeyboard(() => {
|
||||
setIsFocused(true);
|
||||
});
|
||||
|
||||
return isFocused;
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
LARGE_PASTE_CHAR_THRESHOLD,
|
||||
LARGE_PASTE_LINE_THRESHOLD,
|
||||
applyCompletion,
|
||||
atCategoryTabs,
|
||||
codePointIndexToDisplayCol,
|
||||
codePointIndexToDisplayOffset,
|
||||
commandCompletionItemsToSuggestions,
|
||||
@@ -25,11 +26,13 @@ import {
|
||||
displayColToCodePointIndex,
|
||||
displayOffsetToCodePointIndex,
|
||||
expandPendingPastePlaceholders,
|
||||
filterByCategory,
|
||||
freePastePlaceholderId,
|
||||
isLargePaste,
|
||||
isPerfectMatchForTarget,
|
||||
isPerfectSlashMatch,
|
||||
largePastePlaceholder,
|
||||
nextCategory,
|
||||
nextLargePastePlaceholder,
|
||||
normalizePastedText,
|
||||
parsePastePlaceholder,
|
||||
@@ -787,3 +790,55 @@ describe('display-width ↔ code-point cursor conversion (R2-1)', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('@ completion category rules (#143)', () => {
|
||||
const row = (value: string, category?: Suggestion['category']): Suggestion =>
|
||||
({ value, label: value, category }) as Suggestion;
|
||||
|
||||
it('derives tabs in ink CATEGORY_ORDER, not in arrival order', () => {
|
||||
const tabs = atCategoryTabs([
|
||||
row('session:a', 'session'),
|
||||
row('ext', 'extension'),
|
||||
row('file.txt'),
|
||||
row('@server:uri', 'mcp'),
|
||||
]);
|
||||
expect(tabs).toEqual(['all', 'file', 'session', 'mcp', 'extension']);
|
||||
});
|
||||
|
||||
it('yields no tabs for a single category, which is what hides the bar', () => {
|
||||
expect(atCategoryTabs([row('a.txt'), row('b.txt')])).toEqual(['all']);
|
||||
expect(atCategoryTabs([])).toEqual(['all']);
|
||||
});
|
||||
|
||||
it('counts a category-less row as a file', () => {
|
||||
expect(atCategoryTabs([row('a.txt'), row('session:a', 'session')])).toEqual(
|
||||
['all', 'file', 'session'],
|
||||
);
|
||||
expect(
|
||||
filterByCategory([row('a.txt'), row('s', 'session')], 'file'),
|
||||
).toEqual([row('a.txt')]);
|
||||
});
|
||||
|
||||
it('filters to the active tab and passes everything through on all', () => {
|
||||
const rows = [row('a.txt'), row('session:a', 'session')];
|
||||
expect(filterByCategory(rows, 'all')).toEqual(rows);
|
||||
expect(filterByCategory(rows, 'session')).toEqual([
|
||||
row('session:a', 'session'),
|
||||
]);
|
||||
expect(filterByCategory(rows, 'mcp')).toEqual([]);
|
||||
});
|
||||
|
||||
it('steps tabs with wrap in both directions', () => {
|
||||
const tabs = ['all', 'file', 'session'] as const;
|
||||
expect(nextCategory(tabs, 'all', 1)).toBe('file');
|
||||
expect(nextCategory(tabs, 'session', 1)).toBe('all');
|
||||
expect(nextCategory(tabs, 'all', -1)).toBe('session');
|
||||
expect(nextCategory(tabs, 'file', -1)).toBe('all');
|
||||
});
|
||||
|
||||
it('falls back to all when the active tab left the result set', () => {
|
||||
const tabs = ['all', 'file'] as const;
|
||||
expect(nextCategory(tabs, 'session', 1)).toBe('all');
|
||||
expect(nextCategory(tabs, 'session', -1)).toBe('all');
|
||||
});
|
||||
});
|
||||
@@ -28,9 +28,8 @@
|
||||
* and the keyboard; it delegates every decision here.
|
||||
*/
|
||||
|
||||
import { escapePath } from '@qwen-code/qwen-code-core/utils/paths.js';
|
||||
import { Fzf, type FzfResultItem } from 'fzf';
|
||||
import type { Suggestion } from '../utils/suggestions.js';
|
||||
import type { Suggestion, SuggestionCategory } from '../utils/suggestions.js';
|
||||
import { MAX_SUGGESTIONS_TO_SHOW } from '../utils/suggestions.js';
|
||||
import {
|
||||
CommandKind,
|
||||
@@ -929,18 +928,46 @@ export function applyCompletion(
|
||||
};
|
||||
}
|
||||
|
||||
/** Fixed display order of `@` category tabs (ink's CATEGORY_ORDER). */
|
||||
const CATEGORY_ORDER: SuggestionCategory[] = [
|
||||
'file',
|
||||
'session',
|
||||
'mcp',
|
||||
'extension',
|
||||
];
|
||||
|
||||
export type CompletionCategory = SuggestionCategory | 'all';
|
||||
|
||||
/**
|
||||
* Maps core FileSearch results onto @-completion suggestions, mirroring
|
||||
* useAtCompletion's mapping (directories keep their trailing '/', the value
|
||||
* is the shell-escaped path).
|
||||
* Tabs for a raw `@` suggestion set. A single present category yields just
|
||||
* `['all']`, which is what keeps the bar hidden for files-only results.
|
||||
*/
|
||||
export function fileSearchToSuggestions(paths: string[]): Suggestion[] {
|
||||
return paths.map((p) => ({
|
||||
label: p,
|
||||
value: escapePath(p),
|
||||
isDirectory: p.endsWith('/'),
|
||||
category: 'file' as const,
|
||||
}));
|
||||
export function atCategoryTabs(
|
||||
suggestions: readonly Suggestion[],
|
||||
): CompletionCategory[] {
|
||||
const present = new Set(suggestions.map((s) => s.category ?? 'file'));
|
||||
const ordered = CATEGORY_ORDER.filter((c) => present.has(c));
|
||||
return ordered.length > 1 ? ['all', ...ordered] : ['all'];
|
||||
}
|
||||
|
||||
export function filterByCategory(
|
||||
suggestions: readonly Suggestion[],
|
||||
category: CompletionCategory,
|
||||
): readonly Suggestion[] {
|
||||
return category === 'all'
|
||||
? suggestions
|
||||
: suggestions.filter((s) => (s.category ?? 'file') === category);
|
||||
}
|
||||
|
||||
/** Step one tab, wrapping; a tab that left the set falls back to `'all'`. */
|
||||
export function nextCategory(
|
||||
tabs: readonly CompletionCategory[],
|
||||
active: CompletionCategory,
|
||||
direction: 1 | -1,
|
||||
): CompletionCategory {
|
||||
const idx = tabs.indexOf(active);
|
||||
if (idx === -1) return 'all';
|
||||
return tabs[(idx + direction + tabs.length) % tabs.length];
|
||||
}
|
||||
|
||||
/** What the view should show in the suggestion window. */
|
||||
|
||||
@@ -52,6 +52,13 @@ interface FakeEditor {
|
||||
newLine(): void;
|
||||
}
|
||||
|
||||
type MockSuggestion = {
|
||||
label: string;
|
||||
value: string;
|
||||
description?: string;
|
||||
category?: 'file' | 'session' | 'mcp' | 'extension';
|
||||
};
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const state = {
|
||||
inputHandlers: [] as Array<(sequence: string) => boolean>,
|
||||
@@ -61,6 +68,8 @@ const mocks = vi.hoisted(() => {
|
||||
slashCommands: [] as unknown[],
|
||||
fileSearchResults: [] as string[],
|
||||
fileSearchDelay: Promise.resolve() as Promise<void>,
|
||||
sessionSuggestions: [] as MockSuggestion[],
|
||||
extensionSuggestions: [] as MockSuggestion[],
|
||||
textareaProps: null as Record<string, unknown> | null,
|
||||
};
|
||||
|
||||
@@ -219,23 +228,31 @@ vi.mock('@opentui/react', () => ({
|
||||
useTerminalDimensions: () => ({ width: 80, height: 24 }),
|
||||
}));
|
||||
|
||||
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
|
||||
return {
|
||||
...actual,
|
||||
FileSearchFactory: {
|
||||
create: () => ({
|
||||
initialize: async () => {},
|
||||
search: async () => {
|
||||
await mocks.state.fileSearchDelay;
|
||||
return mocks.state.fileSearchResults;
|
||||
},
|
||||
dispose: async () => {},
|
||||
}),
|
||||
},
|
||||
};
|
||||
});
|
||||
// The composer delegates `@` completion to ink's useAtCompletion, which
|
||||
// resolves the crawler through the core subpath and lists prior sessions off
|
||||
// disk. Mock both so the tests never crawl the real project root.
|
||||
vi.mock('@qwen-code/qwen-code-core/utils/filesearch/fileSearch.js', () => ({
|
||||
FileSearchFactory: {
|
||||
create: () => ({
|
||||
initialize: async () => {},
|
||||
search: async () => {
|
||||
await mocks.state.fileSearchDelay;
|
||||
return mocks.state.fileSearchResults;
|
||||
},
|
||||
dispose: async () => {},
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../hooks/session-completion.js', () => ({
|
||||
getSessionSuggestions: async () => mocks.state.sessionSuggestions,
|
||||
}));
|
||||
|
||||
// The real helper returns [] without a Config, so a third `@` category has to
|
||||
// come from here.
|
||||
vi.mock('../hooks/extension-mention-ref.js', () => ({
|
||||
getExtensionSuggestions: () => mocks.state.extensionSuggestions,
|
||||
}));
|
||||
|
||||
vi.mock('@opentui/react/jsx-runtime', () => mocks.buildJsxRuntime());
|
||||
vi.mock('@opentui/react/jsx-dev-runtime', () => mocks.buildJsxRuntime());
|
||||
@@ -467,6 +484,7 @@ describe('OpenTuiInputPrompt submit guard', () => {
|
||||
mocks.state.slashCommands = [];
|
||||
mocks.state.fileSearchResults = [];
|
||||
mocks.state.fileSearchDelay = Promise.resolve();
|
||||
mocks.state.sessionSuggestions = [];
|
||||
});
|
||||
|
||||
it('Esc invalidates in-flight @ searches: a late resolve must not reopen the dropdown (R2-2)', async () => {
|
||||
@@ -696,7 +714,7 @@ describe('OpenTuiInputPrompt submit guard', () => {
|
||||
expect(onInterrupt).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('`!` does not toggle shell mode while a stale dropdown is open (R1-54)', async () => {
|
||||
it('a late @ resolution cannot reopen the dropdown over a cleared buffer (R1-54)', async () => {
|
||||
let releaseSearch!: () => void;
|
||||
mocks.state.fileSearchResults = ['hit-file.txt'];
|
||||
mocks.state.fileSearchDelay = new Promise<void>((resolve) => {
|
||||
@@ -713,22 +731,26 @@ describe('OpenTuiInputPrompt submit guard', () => {
|
||||
const editor = currentEditor();
|
||||
await typeText('@x');
|
||||
await act(async () => {});
|
||||
// Clear the buffer by Ctrl+C: the stale search result repopulates the
|
||||
// dropdown over the empty buffer, exactly ink's !showCompletionSuggestions
|
||||
// hazard.
|
||||
// Clear the buffer by Ctrl+C while the search is still pending.
|
||||
await act(async () => {
|
||||
lastKeyboardHandler()(
|
||||
baseKeyEvent({ name: 'c', sequence: '\x03', ctrl: true }),
|
||||
);
|
||||
});
|
||||
expect(editor.plainText).toBe('');
|
||||
// The clear flipped the completion mode to IDLE before the search settled,
|
||||
// and the shared hook's callbacks are gated on that mode, so the late
|
||||
// result is dropped instead of repopulating the dropdown over an empty
|
||||
// buffer. Ink needed its `!showCompletionSuggestions` guard for exactly
|
||||
// this stale dropdown; that guard stays for slash argument completion,
|
||||
// which this renderer still races with a sequence counter of its own.
|
||||
releaseSearch();
|
||||
await act(async () => {});
|
||||
await act(async () => {
|
||||
lastKeyboardHandler()(baseKeyEvent({ name: '!', sequence: '!' }));
|
||||
});
|
||||
expect(onToggleShellMode).not.toHaveBeenCalled();
|
||||
expect(editor.plainText).toBe('!');
|
||||
expect(onToggleShellMode).toHaveBeenCalledTimes(1);
|
||||
expect(editor.plainText).toBe('');
|
||||
});
|
||||
|
||||
it('Up at the top edge pops queued prompts into the composer', async () => {
|
||||
@@ -1108,6 +1130,28 @@ describe('OpenTuiInputPrompt Enter accepts completions (G-13)', () => {
|
||||
expect(editor.plainText).toBe('/help ');
|
||||
});
|
||||
|
||||
it('Tab in the same burst as the arrows accepts the row they reached', async () => {
|
||||
const submitted: string[] = [];
|
||||
await renderWithCommands(
|
||||
[
|
||||
{ name: 'help', description: 'Show help', kind: 'built-in' },
|
||||
{ name: 'hooks', description: 'Manage hooks', kind: 'built-in' },
|
||||
],
|
||||
(text) => submitted.push(text),
|
||||
);
|
||||
const editor = currentEditor();
|
||||
await typeText('/');
|
||||
// One burst: the renderer delivers both keys to the handler registered
|
||||
// before either took effect.
|
||||
await act(async () => {
|
||||
const handler = lastKeyboardHandler();
|
||||
handler(baseKeyEvent({ name: 'down', sequence: '\x1b[B' }));
|
||||
handler(baseKeyEvent({ name: 'tab', sequence: '\t' }));
|
||||
});
|
||||
expect(submitted).toEqual([]);
|
||||
expect(editor.plainText).toBe('/hooks ');
|
||||
});
|
||||
|
||||
it('a perfect match submits directly on Enter', async () => {
|
||||
const submitted: string[] = [];
|
||||
await renderWithCommands(
|
||||
@@ -1359,6 +1403,7 @@ describe('OpenTuiInputPrompt follow-up suggestion (U-7)', () => {
|
||||
mocks.state.slashCommands = [];
|
||||
mocks.state.fileSearchResults = [];
|
||||
mocks.state.fileSearchDelay = Promise.resolve();
|
||||
mocks.state.sessionSuggestions = [];
|
||||
mocks.state.textareaProps = null;
|
||||
});
|
||||
|
||||
@@ -1763,3 +1808,161 @@ describe('OpenTuiInputPrompt Windows Tab approval-mode fallback (F-2)', () => {
|
||||
expect(cycles).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('OpenTuiInputPrompt @ completion categories (#143)', () => {
|
||||
const SESSION_ROW = {
|
||||
label: 'Fix the flaky test',
|
||||
value: 'session:abc123',
|
||||
description: '2 hours ago',
|
||||
category: 'session' as const,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.state.inputHandlers.length = 0;
|
||||
mocks.state.keyboardHandlers.length = 0;
|
||||
mocks.state.editors.length = 0;
|
||||
mocks.state.pasteHandlers.length = 0;
|
||||
mocks.state.slashCommands = [];
|
||||
mocks.state.fileSearchResults = [];
|
||||
mocks.state.fileSearchDelay = Promise.resolve();
|
||||
mocks.state.sessionSuggestions = [];
|
||||
mocks.state.extensionSuggestions = [];
|
||||
});
|
||||
|
||||
async function openAtCompletion() {
|
||||
const { container } = render(
|
||||
<OpenTuiInputPrompt onSubmit={() => {}} userMessages={[]} />,
|
||||
);
|
||||
await act(async () => {});
|
||||
await typeText('@');
|
||||
// One flush for the crawler's initialize(), one for the search promise.
|
||||
await act(async () => {});
|
||||
await act(async () => {});
|
||||
return container;
|
||||
}
|
||||
|
||||
async function pressArrow(name: 'left' | 'right'): Promise<void> {
|
||||
await act(async () => {
|
||||
lastKeyboardHandler()(baseKeyEvent({ name, sequence: '' }));
|
||||
});
|
||||
}
|
||||
|
||||
it('draws the category tab bar when results span more than one category', async () => {
|
||||
mocks.state.fileSearchResults = ['hit-file.txt'];
|
||||
mocks.state.sessionSuggestions = [SESSION_ROW];
|
||||
const { textContent } = await openAtCompletion();
|
||||
const text = textContent ?? '';
|
||||
expect(text).toContain(' All ');
|
||||
expect(text).toContain(' Files ');
|
||||
expect(text).toContain(' Sessions ');
|
||||
expect(text).toContain('(←/→ to switch)');
|
||||
// Both sources are on screen under the default `all` tab.
|
||||
expect(text).toContain('hit-file.txt');
|
||||
expect(text).toContain('Fix the flaky test');
|
||||
});
|
||||
|
||||
it('hides the bar for a files-only result set', async () => {
|
||||
mocks.state.fileSearchResults = ['hit-file.txt'];
|
||||
const { textContent } = await openAtCompletion();
|
||||
const text = textContent ?? '';
|
||||
expect(text).toContain('hit-file.txt');
|
||||
expect(text).not.toContain('(←/→ to switch)');
|
||||
expect(text).not.toContain(' Files ');
|
||||
});
|
||||
|
||||
it('filters the rows to the tab the arrows land on, wrapping at both ends', async () => {
|
||||
mocks.state.fileSearchResults = ['hit-file.txt'];
|
||||
mocks.state.sessionSuggestions = [SESSION_ROW];
|
||||
const container = await openAtCompletion();
|
||||
|
||||
await pressArrow('right'); // all → file
|
||||
expect(container.textContent).toContain('hit-file.txt');
|
||||
expect(container.textContent).not.toContain('Fix the flaky test');
|
||||
|
||||
await pressArrow('right'); // file → session
|
||||
expect(container.textContent).toContain('Fix the flaky test');
|
||||
expect(container.textContent).not.toContain('hit-file.txt');
|
||||
|
||||
await pressArrow('right'); // session → all (wrap)
|
||||
expect(container.textContent).toContain('hit-file.txt');
|
||||
expect(container.textContent).toContain('Fix the flaky test');
|
||||
|
||||
await pressArrow('left'); // all → session (wrap backwards)
|
||||
expect(container.textContent).toContain('Fix the flaky test');
|
||||
expect(container.textContent).not.toContain('hit-file.txt');
|
||||
});
|
||||
|
||||
it('accepts a session row as its reference, not as a path', async () => {
|
||||
mocks.state.fileSearchResults = ['hit-file.txt'];
|
||||
mocks.state.sessionSuggestions = [SESSION_ROW];
|
||||
await openAtCompletion();
|
||||
const editor = currentEditor();
|
||||
|
||||
await pressArrow('right'); // all → file
|
||||
await pressArrow('right'); // file → session: one row, already highlighted
|
||||
await act(async () => {
|
||||
lastKeyboardHandler()(baseKeyEvent({ name: 'tab', sequence: '\t' }));
|
||||
});
|
||||
expect(editor.plainText).toBe('@session:abc123 ');
|
||||
});
|
||||
|
||||
it('steps from the raw tab, so a category the results dropped lands on All', async () => {
|
||||
mocks.state.fileSearchResults = ['hit-file.txt'];
|
||||
mocks.state.sessionSuggestions = [SESSION_ROW];
|
||||
mocks.state.extensionSuggestions = [
|
||||
{
|
||||
label: '@ext:lint',
|
||||
value: '@ext:lint',
|
||||
description: 'Extension',
|
||||
category: 'extension' as const,
|
||||
},
|
||||
];
|
||||
const container = await openAtCompletion();
|
||||
|
||||
await pressArrow('right'); // all → file
|
||||
await pressArrow('right'); // file → session
|
||||
expect(container.textContent).toContain('Fix the flaky test');
|
||||
|
||||
// A newer result set drops the session category and keeps two others, so
|
||||
// the bar stays up while the tab the state names is no longer on it.
|
||||
mocks.state.sessionSuggestions = [];
|
||||
await typeText('x');
|
||||
await act(async () => {});
|
||||
await act(async () => {});
|
||||
expect(container.textContent).not.toContain(' Sessions ');
|
||||
|
||||
// ink steps from its raw state: the index lookup misses and the step lands
|
||||
// on 'all', which still shows every remaining row. Stepping from the
|
||||
// derived tab instead would land on Files and hide the extension row.
|
||||
await pressArrow('right');
|
||||
expect(container.textContent).toContain('hit-file.txt');
|
||||
expect(container.textContent).toContain('@ext:lint');
|
||||
});
|
||||
|
||||
it('steps the tab once per arrow of a single read', async () => {
|
||||
mocks.state.fileSearchResults = ['hit-file.txt'];
|
||||
mocks.state.sessionSuggestions = [SESSION_ROW];
|
||||
mocks.state.extensionSuggestions = [
|
||||
{
|
||||
label: '@ext:lint',
|
||||
value: '@ext:lint',
|
||||
description: 'Extension',
|
||||
category: 'extension' as const,
|
||||
},
|
||||
];
|
||||
const container = await openAtCompletion();
|
||||
|
||||
// All → Files → Sessions → Extensions out of one stdin read. Read from the
|
||||
// render that armed the handler, every arrow steps from 'all' and the burst
|
||||
// lands on Files.
|
||||
await act(async () => {
|
||||
const handler = lastKeyboardHandler();
|
||||
for (let i = 0; i < 3; i++) {
|
||||
handler(baseKeyEvent({ name: 'right', sequence: '' }));
|
||||
}
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain('@ext:lint');
|
||||
expect(container.textContent).not.toContain('hit-file.txt');
|
||||
});
|
||||
});
|
||||
@@ -21,8 +21,10 @@
|
||||
* - history: ↑/↓ (and Ctrl+P/N) walk the submitted prompts through the
|
||||
* ported InputHistory with the original two-step edge transition;
|
||||
* - completions: `/command` suggestions from the real interactive command
|
||||
* registry and `@file` suggestions from core's FileSearch, with the
|
||||
* original accept rules (Tab/Enter, trailing space, directory drill-in);
|
||||
* registry and `@` suggestions from ink's own useAtCompletion — files,
|
||||
* prior sessions, MCP references and extensions behind a category tab bar —
|
||||
* with the original accept rules (Tab/Enter, trailing space, directory
|
||||
* drill-in);
|
||||
* - Esc: double-Esc clears the buffer (footer-style "Press Esc again to
|
||||
* clear." hint surfaced via onEscapeArmedChange); while streaming Esc
|
||||
* interrupts instead (in shell mode it exits the mode first, and also
|
||||
@@ -50,13 +52,7 @@ import {
|
||||
} from './input-prompt-key.js';
|
||||
import type { KeyEvent, PasteEvent, TextareaRenderable } from '@opentui/core';
|
||||
import { decodePasteBytes } from '@opentui/core';
|
||||
import {
|
||||
FileSearchFactory,
|
||||
ApprovalMode,
|
||||
Storage,
|
||||
type Config,
|
||||
type FileSearch,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { ApprovalMode, Storage, type Config } from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
clipboardHasImage,
|
||||
saveClipboardImage,
|
||||
@@ -68,7 +64,11 @@ import type { RecentSlashCommand } from '../hooks/useSlashCompletion.js';
|
||||
import { normalizeDescription, type Suggestion } from '../utils/suggestions.js';
|
||||
import { cpLen, toCodePoints, truncateToWidth } from '../utils/textUtils.js';
|
||||
import { C } from './theme.js';
|
||||
import { useBatchSafeCursor, useBatchSafeState } from './batch-cursor.js';
|
||||
import { useFollowupSuggestionsCLI } from '../hooks/useFollowupSuggestions.js';
|
||||
import { useAtCompletion } from '../hooks/useAtCompletion.js';
|
||||
import { categoryLabel } from '../components/SuggestionsDisplay.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import { InputHistory } from './input-history.js';
|
||||
import { loadInteractiveCommands } from './slash-dispatch.js';
|
||||
import {
|
||||
@@ -76,6 +76,7 @@ import {
|
||||
EscapeClearModel,
|
||||
MAX_SUGGESTIONS_TO_SHOW,
|
||||
applyCompletion,
|
||||
atCategoryTabs,
|
||||
codePointIndexToDisplayCol,
|
||||
codePointIndexToDisplayOffset,
|
||||
commandCompletionItemsToSuggestions,
|
||||
@@ -84,12 +85,13 @@ import {
|
||||
displayColToCodePointIndex,
|
||||
displayOffsetToCodePointIndex,
|
||||
expandPendingPastePlaceholders,
|
||||
fileSearchToSuggestions,
|
||||
filterByCategory,
|
||||
freePastePlaceholderId,
|
||||
historyDownDecision,
|
||||
historyUpDecision,
|
||||
isLargePaste,
|
||||
isPerfectMatchForTarget,
|
||||
nextCategory,
|
||||
nextLargePastePlaceholder,
|
||||
normalizePastedText,
|
||||
parsePastePlaceholder,
|
||||
@@ -98,6 +100,7 @@ import {
|
||||
slashCompletionPositions,
|
||||
subcommandSuggestions,
|
||||
suggestionWindow,
|
||||
type CompletionCategory,
|
||||
} from './input-prompt-model.js';
|
||||
|
||||
/**
|
||||
@@ -150,6 +153,8 @@ function buildCompletionContext(
|
||||
|
||||
const DEFAULT_PLACEHOLDER = ' Type your message or @path/to/file';
|
||||
const ESCAPE_ARM_HINT = 'Press Esc again to clear.';
|
||||
/** Floor a described `@` row keeps for its description (ink parity). */
|
||||
const MIN_DESCRIPTION_WIDTH = 12;
|
||||
|
||||
/** Approval-mode chrome exactly like InputPrompt's statusColor/prefix. */
|
||||
function promptChrome(approvalMode: ApprovalMode | undefined): {
|
||||
@@ -158,11 +163,11 @@ function promptChrome(approvalMode: ApprovalMode | undefined): {
|
||||
} {
|
||||
switch (approvalMode) {
|
||||
case ApprovalMode.AUTO_EDIT:
|
||||
return { prefix: '>', color: C.yellow };
|
||||
return { prefix: '>', color: C.warningDim };
|
||||
case ApprovalMode.AUTO:
|
||||
return { prefix: '>', color: C.accent };
|
||||
return { prefix: '>', color: C.purple };
|
||||
case ApprovalMode.YOLO:
|
||||
return { prefix: '*', color: C.red };
|
||||
return { prefix: '*', color: C.errorDim };
|
||||
case ApprovalMode.PLAN:
|
||||
case ApprovalMode.DEFAULT:
|
||||
return { prefix: '>' };
|
||||
@@ -278,8 +283,29 @@ export function OpenTuiInputPrompt(props: InputPromptProps) {
|
||||
}
|
||||
|
||||
const [textVersion, setTextVersion] = useState(0);
|
||||
const [suggestions, setSuggestions] = useState<readonly Suggestion[]>([]);
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
// Raw producer output. `suggestions` below is this list filtered to the
|
||||
// active `@` category tab, so navigation, acceptance and the visible window
|
||||
// all index the same rows the user sees (ink useCompletion).
|
||||
const [rawSuggestions, setRawSuggestions] = useState<readonly Suggestion[]>(
|
||||
[],
|
||||
);
|
||||
const {
|
||||
value: activeCategory,
|
||||
ref: activeCategoryRef,
|
||||
setValue: setActiveCategory,
|
||||
} = useBatchSafeState<CompletionCategory>('all');
|
||||
const categoryTabs = atCategoryTabs(rawSuggestions);
|
||||
// Derived rather than corrected in an effect: a tab that a newer result set
|
||||
// no longer contains reads as 'all' for this render, which is ink's fallback.
|
||||
const atCategory = categoryTabs.includes(activeCategory)
|
||||
? activeCategory
|
||||
: 'all';
|
||||
const suggestions = filterByCategory(rawSuggestions, atCategory);
|
||||
const {
|
||||
cursor: activeIndex,
|
||||
cursorRef: activeIndexRef,
|
||||
setCursor: setActiveIndex,
|
||||
} = useBatchSafeCursor();
|
||||
const [loadingSuggestions, setLoadingSuggestions] = useState(false);
|
||||
const [escapeArmed, setEscapeArmed] = useState(false);
|
||||
const [attachments, setAttachments] = useState<
|
||||
@@ -290,9 +316,6 @@ export function OpenTuiInputPrompt(props: InputPromptProps) {
|
||||
// original's isHistoryRestoredText.
|
||||
const historyRestoredTextRef = useRef<string | null>(null);
|
||||
const dismissedUntilChangeRef = useRef<string | null>(null);
|
||||
const fileSearchRef = useRef<FileSearch | null>(null);
|
||||
const fileSearchReadyRef = useRef<Promise<void> | null>(null);
|
||||
const atSearchSeqRef = useRef(0);
|
||||
const commandsRef = useRef<readonly SlashCommand[]>([]);
|
||||
// Query-relative replacement range for the current buffer's SLASH target.
|
||||
// The perfect-match verdict is deliberately not cached alongside it: Enter
|
||||
@@ -343,9 +366,11 @@ export function OpenTuiInputPrompt(props: InputPromptProps) {
|
||||
// over the approval-mode prefix). The label itself lives in the footer, as
|
||||
// ink's ShellModeIndicator does.
|
||||
const chrome = shellModeActive
|
||||
? { prefix: '!', color: C.accent }
|
||||
? { prefix: '!', color: C.symbol }
|
||||
: promptChrome(approvalMode);
|
||||
const borderColor = chrome.color ?? C.accent;
|
||||
const borderColor = focus
|
||||
? (chrome.color ?? C.borderFocused)
|
||||
: C.borderDefault;
|
||||
|
||||
// ── real command registry feeding /-completion ──────────────────────────
|
||||
useEffect(() => {
|
||||
@@ -360,41 +385,35 @@ export function OpenTuiInputPrompt(props: InputPromptProps) {
|
||||
};
|
||||
}, [config]);
|
||||
|
||||
// ── @-completion file index (core FileSearch, like useAtCompletion) ─────
|
||||
// ── @-completion sources (files, sessions, MCP, extensions) ──────────────
|
||||
// ink's own hook, reused verbatim: it owns the FileSearch lifecycle and the
|
||||
// merge order (mcp → file → session). `refreshCompletion` publishes the
|
||||
// query; null disables the hook.
|
||||
const projectRoot = config?.getTargetDir() ?? process.cwd();
|
||||
const ensureFileSearch = useCallback((): Promise<void> => {
|
||||
if (fileSearchReadyRef.current) return fileSearchReadyRef.current;
|
||||
const searcher = FileSearchFactory.create({
|
||||
projectRoot,
|
||||
ignoreDirs: [],
|
||||
useGitignore: config?.getFileFilteringOptions()?.respectGitIgnore ?? true,
|
||||
useQwenignore:
|
||||
config?.getFileFilteringOptions()?.respectQwenIgnore ?? true,
|
||||
customIgnoreFiles: config?.getFileFilteringOptions()?.customIgnoreFiles,
|
||||
cache: true,
|
||||
cacheTtl: 30,
|
||||
enableRecursiveFileSearch: config?.getEnableRecursiveFileSearch() ?? true,
|
||||
enableFuzzySearch: config?.getFileFilteringEnableFuzzySearch() !== false,
|
||||
});
|
||||
fileSearchReadyRef.current = searcher
|
||||
.initialize()
|
||||
.then(() => {
|
||||
fileSearchRef.current = searcher;
|
||||
})
|
||||
.catch(() => {
|
||||
fileSearchReadyRef.current = null;
|
||||
});
|
||||
return fileSearchReadyRef.current;
|
||||
}, [config, projectRoot]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
void fileSearchRef.current?.dispose?.();
|
||||
fileSearchRef.current = null;
|
||||
fileSearchReadyRef.current = null;
|
||||
const [atQuery, setAtQuery] = useState<string | null>(null);
|
||||
// Both callbacks are gated on the mode ref: leaving AT makes the hook
|
||||
// dispatch RESET, and that flush lands after the render in which
|
||||
// refreshCompletion already published the slash list for the same keystroke.
|
||||
const onAtSuggestions = useCallback(
|
||||
(next: Suggestion[]) => {
|
||||
if (completionModeRef.current !== CompletionMode.AT) return;
|
||||
setRawSuggestions(next);
|
||||
setActiveIndex(0);
|
||||
},
|
||||
[],
|
||||
[setActiveIndex],
|
||||
);
|
||||
const onAtLoading = useCallback((loading: boolean) => {
|
||||
if (completionModeRef.current !== CompletionMode.AT) return;
|
||||
setLoadingSuggestions(loading);
|
||||
}, []);
|
||||
useAtCompletion({
|
||||
enabled: atQuery !== null,
|
||||
pattern: atQuery ?? '',
|
||||
config: config ?? undefined,
|
||||
cwd: projectRoot,
|
||||
setSuggestions: onAtSuggestions,
|
||||
setIsLoadingSuggestions: onAtLoading,
|
||||
});
|
||||
|
||||
// The completion target for the buffer as it stands right now. Read from the
|
||||
// editor, never from published state, so a key that lands before the
|
||||
@@ -432,13 +451,17 @@ export function OpenTuiInputPrompt(props: InputPromptProps) {
|
||||
completionModeRef.current = CompletionMode.IDLE;
|
||||
slashRangeRef.current = null;
|
||||
suggestionNavigatedRef.current = false;
|
||||
setSuggestions([]);
|
||||
setAtQuery(null);
|
||||
setActiveCategory('all');
|
||||
setRawSuggestions([]);
|
||||
setActiveIndex(0);
|
||||
setLoadingSuggestions(false);
|
||||
return;
|
||||
}
|
||||
|
||||
completionModeRef.current = target.mode;
|
||||
// Publishing the query is the whole AT branch: the hook does the search.
|
||||
setAtQuery(target.mode === CompletionMode.AT ? target.query : null);
|
||||
// Any buffer change invalidates dropdown navigation (ink resets
|
||||
// navigatedRef when the query changes).
|
||||
suggestionNavigatedRef.current = false;
|
||||
@@ -467,11 +490,13 @@ export function OpenTuiInputPrompt(props: InputPromptProps) {
|
||||
void complete(context, parsed.argumentString)
|
||||
.then((results) => {
|
||||
if (slashSearchSeqRef.current !== seq) return;
|
||||
setSuggestions(commandCompletionItemsToSuggestions(results ?? []));
|
||||
setRawSuggestions(
|
||||
commandCompletionItemsToSuggestions(results ?? []),
|
||||
);
|
||||
setActiveIndex(0);
|
||||
})
|
||||
.catch(() => {
|
||||
if (slashSearchSeqRef.current === seq) setSuggestions([]);
|
||||
if (slashSearchSeqRef.current === seq) setRawSuggestions([]);
|
||||
})
|
||||
.finally(() => {
|
||||
if (slashSearchSeqRef.current === seq) setLoadingSuggestions(false);
|
||||
@@ -482,38 +507,13 @@ export function OpenTuiInputPrompt(props: InputPromptProps) {
|
||||
// Sub-command level: ranked candidates from the parsed command tree
|
||||
// (`/cmd ` → its subCommands, `/dir ad` → `add`), recency-weighted.
|
||||
slashSearchSeqRef.current++;
|
||||
setSuggestions(
|
||||
setRawSuggestions(
|
||||
subcommandSuggestions(parsed, recentSlashCommandsRef.current),
|
||||
);
|
||||
setActiveIndex(0);
|
||||
setLoadingSuggestions(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// AT: async file search; a sequence guard drops stale results.
|
||||
const seq = ++atSearchSeqRef.current;
|
||||
setLoadingSuggestions(true);
|
||||
void ensureFileSearch().then(async () => {
|
||||
if (atSearchSeqRef.current !== seq) return;
|
||||
const searcher = fileSearchRef.current;
|
||||
if (!searcher) {
|
||||
setLoadingSuggestions(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const results = await searcher.search(target.query, {
|
||||
maxResults: MAX_SUGGESTIONS_TO_SHOW * 3,
|
||||
});
|
||||
if (atSearchSeqRef.current !== seq) return;
|
||||
setSuggestions(fileSearchToSuggestions(results));
|
||||
setActiveIndex(0);
|
||||
} catch {
|
||||
if (atSearchSeqRef.current === seq) setSuggestions([]);
|
||||
} finally {
|
||||
if (atSearchSeqRef.current === seq) setLoadingSuggestions(false);
|
||||
}
|
||||
});
|
||||
}, [ensureFileSearch, config, currentCompletionTarget]);
|
||||
}, [config, currentCompletionTarget, setActiveCategory, setActiveIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = editorRef.current;
|
||||
@@ -588,7 +588,7 @@ export function OpenTuiInputPrompt(props: InputPromptProps) {
|
||||
setTextVersion((v) => v + 1);
|
||||
historyRef.current?.reset();
|
||||
historyRestoredTextRef.current = null;
|
||||
setSuggestions([]);
|
||||
setRawSuggestions([]);
|
||||
setAttachments([]);
|
||||
onSubmit(finalText, images.length > 0 ? images : undefined);
|
||||
// Same dismissal as the real submit path below: a submitOnAccept
|
||||
@@ -801,7 +801,7 @@ export function OpenTuiInputPrompt(props: InputPromptProps) {
|
||||
isPerfectMatchForTarget(liveTarget, commandsRef.current);
|
||||
if (showing && (!isPerfectMatch || suggestionNavigatedRef.current)) {
|
||||
key.preventDefault();
|
||||
acceptSuggestion(activeIndex, true);
|
||||
acceptSuggestion(activeIndexRef.current, true);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -851,7 +851,7 @@ export function OpenTuiInputPrompt(props: InputPromptProps) {
|
||||
setAttachments([]);
|
||||
historyRef.current?.reset();
|
||||
historyRestoredTextRef.current = null;
|
||||
setSuggestions([]);
|
||||
setRawSuggestions([]);
|
||||
setLoadingSuggestions(false);
|
||||
onSubmit(finalText, images.length > 0 ? images : undefined);
|
||||
// Ink dismisses on submit so a synchronous command (/clear, /help)
|
||||
@@ -940,12 +940,14 @@ export function OpenTuiInputPrompt(props: InputPromptProps) {
|
||||
}
|
||||
if (completionModeRef.current !== CompletionMode.IDLE) {
|
||||
completionModeRef.current = CompletionMode.IDLE;
|
||||
setSuggestions([]);
|
||||
setRawSuggestions([]);
|
||||
setLoadingSuggestions(false);
|
||||
// Invalidate in-flight searches: an async resolution landing after
|
||||
// the Esc would otherwise re-populate the dismissed dropdown and
|
||||
// turn the next Enter into an accidental suggestion insert.
|
||||
atSearchSeqRef.current++;
|
||||
// turn the next Enter into an accidental suggestion insert. The mode
|
||||
// ref flipped above is what drops the `@` ones — the shared hook's
|
||||
// callbacks are gated on it — and the counter covers slash argument
|
||||
// completion, which the port still drives itself.
|
||||
slashSearchSeqRef.current++;
|
||||
return;
|
||||
}
|
||||
@@ -988,24 +990,52 @@ export function OpenTuiInputPrompt(props: InputPromptProps) {
|
||||
|
||||
const showing = suggestions.length > 0;
|
||||
|
||||
// The visible category tabs own the bare arrows while they are up, exactly
|
||||
// as in ink — modifiers pinned false so Alt+arrow word movement and any
|
||||
// Ctrl+arrow terminal binding still reach the buffer.
|
||||
if (
|
||||
showing &&
|
||||
categoryTabs.length > 2 &&
|
||||
(key.name === 'left' || key.name === 'right') &&
|
||||
!key.shift &&
|
||||
!key.ctrl &&
|
||||
!key.meta
|
||||
) {
|
||||
key.preventDefault();
|
||||
// From the raw state, not the derived tab: when a newer result set dropped
|
||||
// the active category, ink's step finds no index and lands on 'all'.
|
||||
setActiveCategory(
|
||||
nextCategory(
|
||||
categoryTabs,
|
||||
activeCategoryRef.current,
|
||||
key.name === 'right' ? 1 : -1,
|
||||
),
|
||||
);
|
||||
setActiveIndex(0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (showing && (navigationUp || navigationDown)) {
|
||||
key.preventDefault();
|
||||
// Navigation marks the dropdown as user-driven: with a perfect command
|
||||
// match, Enter then accepts the highlighted suggestion instead of
|
||||
// submitting the typed text (ink navigatedRef parity).
|
||||
suggestionNavigatedRef.current = true;
|
||||
setActiveIndex((prev) => {
|
||||
if (navigationUp) {
|
||||
return prev <= 0 ? suggestions.length - 1 : prev - 1;
|
||||
}
|
||||
return prev >= suggestions.length - 1 ? 0 : prev + 1;
|
||||
});
|
||||
const prev = activeIndexRef.current;
|
||||
const last = suggestions.length - 1;
|
||||
let next: number;
|
||||
if (navigationUp) {
|
||||
next = prev <= 0 ? last : prev - 1;
|
||||
} else {
|
||||
next = prev >= last ? 0 : prev + 1;
|
||||
}
|
||||
setActiveIndex(next);
|
||||
return;
|
||||
}
|
||||
|
||||
if (showing && key.name === 'tab' && !key.shift) {
|
||||
key.preventDefault();
|
||||
acceptSuggestion(activeIndex, false);
|
||||
acceptSuggestion(activeIndexRef.current, false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1161,13 +1191,14 @@ export function OpenTuiInputPrompt(props: InputPromptProps) {
|
||||
}, [showDropdown, onSuggestionsVisibilityChange]);
|
||||
|
||||
// Slash rows share one half-width command column so their descriptions line
|
||||
// up. `@` rows get no shared column: this renderer's `@` completion only
|
||||
// yields file paths, so every row takes the whole width and a long path stays
|
||||
// on one line instead of wrapping inside a column sized for a shorter
|
||||
// neighbour. The badge counts toward the column: ink measures label +
|
||||
// argumentHint + sourceBadge, so a `[Skill]` row fits the column it was sized
|
||||
// for. completionModeRef only ever changes inside refreshCompletion, alongside
|
||||
// the setSuggestions that re-renders this block.
|
||||
// up. `@` rows share a column only when they carry a description — sessions,
|
||||
// MCP servers/resources and extensions do, plain file paths do not — so a
|
||||
// long path still takes the whole width instead of wrapping inside a column
|
||||
// sized for a shorter reference. The badge counts toward the column: ink
|
||||
// measures label + argumentHint + sourceBadge, so a `[Skill]` row fits the
|
||||
// column it was sized for. completionModeRef only ever changes inside
|
||||
// refreshCompletion, alongside the setRawSuggestions that re-renders this
|
||||
// block.
|
||||
const fullLabelWidth = (s: Suggestion) =>
|
||||
[s.label ?? s.value, s.argumentHint, s.sourceBadge]
|
||||
.filter(Boolean)
|
||||
@@ -1175,12 +1206,21 @@ export function OpenTuiInputPrompt(props: InputPromptProps) {
|
||||
const slashColumn = completionModeRef.current === CompletionMode.SLASH;
|
||||
// The half-width cap applies to ink's `contentWidth` — the row after the
|
||||
// 2-column active marker — not to the terminal width.
|
||||
const contentWidth = Math.max(columns - 2, 1);
|
||||
const describedLabelWidths = suggestions
|
||||
.filter((s) => s.description)
|
||||
.map(fullLabelWidth);
|
||||
const labelColumnWidth = slashColumn
|
||||
? Math.min(
|
||||
Math.max(...suggestions.map(fullLabelWidth), 0),
|
||||
Math.floor(Math.max(columns - 2, 1) * 0.5),
|
||||
Math.floor(contentWidth * 0.5),
|
||||
)
|
||||
: 0;
|
||||
: describedLabelWidths.length > 0
|
||||
? Math.min(
|
||||
Math.max(...describedLabelWidths),
|
||||
Math.max(contentWidth - MIN_DESCRIPTION_WIDTH - 2, 1),
|
||||
)
|
||||
: 0;
|
||||
// What a row actually has left for description text: the dropdown box sits
|
||||
// two columns in on each side, the active marker takes 2, and the description
|
||||
// pays a 2-column gutter. Over-allocating here does not clip — it wraps the
|
||||
@@ -1203,7 +1243,7 @@ export function OpenTuiInputPrompt(props: InputPromptProps) {
|
||||
borderStyle="single"
|
||||
borderColor={borderColor}
|
||||
>
|
||||
<text fg={chrome.color ?? C.purple}>{chrome.prefix} </text>
|
||||
<text fg={chrome.color ?? C.accent}>{chrome.prefix} </text>
|
||||
<textarea
|
||||
ref={(el) => {
|
||||
editorRef.current = el as TextareaRenderable | null;
|
||||
@@ -1235,12 +1275,33 @@ export function OpenTuiInputPrompt(props: InputPromptProps) {
|
||||
{showDropdown && (
|
||||
<box flexDirection="column" marginLeft={2} marginRight={2}>
|
||||
{loadingSuggestions && <text fg={C.dim}>Loading suggestions...</text>}
|
||||
{categoryTabs.length > 2 && (
|
||||
<box flexDirection="row" marginBottom={1}>
|
||||
{categoryTabs.map((cat, i) => {
|
||||
const active = cat === atCategory;
|
||||
return (
|
||||
<box key={cat} marginLeft={i === 0 ? 0 : 1}>
|
||||
<text
|
||||
fg={active ? C.hover : C.dim}
|
||||
bg={active ? C.accent : undefined}
|
||||
>
|
||||
{` ${categoryLabel(cat)} `}
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
})}
|
||||
<box marginLeft={2}>
|
||||
<text fg={C.dim}>{t('(←/→ to switch)')}</text>
|
||||
</box>
|
||||
</box>
|
||||
)}
|
||||
{hasMoreAbove && <text fg={C.text}>▲</text>}
|
||||
{visible.map((suggestion, index) => {
|
||||
const originalIndex = startIndex + index;
|
||||
const isActive = originalIndex === activeIndex;
|
||||
const color = isActive ? C.accent : C.dim;
|
||||
const label = suggestion.label ?? suggestion.value;
|
||||
const sharedColumn = slashColumn || !!suggestion.description;
|
||||
return (
|
||||
<box
|
||||
key={`${suggestion.value}-${originalIndex}`}
|
||||
@@ -1250,12 +1311,12 @@ export function OpenTuiInputPrompt(props: InputPromptProps) {
|
||||
<text fg={color}>{isActive ? '> ' : ' '}</text>
|
||||
</box>
|
||||
<box
|
||||
flexShrink={slashColumn ? 0 : 1}
|
||||
flexShrink={sharedColumn ? 0 : 1}
|
||||
// `"auto"` rather than omitting the attribute: @opentui resets
|
||||
// a removed prop by assigning null, which its width setter
|
||||
// type-guards away, so the slash column would stay stuck on
|
||||
// every `@` row after one slash completion.
|
||||
width={slashColumn ? labelColumnWidth : 'auto'}
|
||||
width={sharedColumn ? labelColumnWidth : 'auto'}
|
||||
>
|
||||
{/* Separate flex children, not one text: an over-long hint then
|
||||
wraps in the width left after the label. Char wrap matches
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
describeGoalCard,
|
||||
describeLegacyGoalCard,
|
||||
foldLiveEvent,
|
||||
type LiveAssistantItem,
|
||||
type LiveHistoryItem,
|
||||
type LiveToolItem,
|
||||
} from './live-session-model.js';
|
||||
@@ -172,6 +173,41 @@ describe('foldLiveEvent tool-queued (approved but not started)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('foldLiveEvent task card (subagent roster parity)', () => {
|
||||
const started = foldLiveEvent([assistant('hi')], {
|
||||
type: 'task-start',
|
||||
id: 's1',
|
||||
name: 'researcher',
|
||||
description: 'bench frames',
|
||||
});
|
||||
|
||||
it('opens a card and appends progress lines', () => {
|
||||
const items = foldLiveEvent(started, {
|
||||
type: 'task-progress',
|
||||
id: 's1',
|
||||
line: '↳ grep',
|
||||
});
|
||||
expect(items[1]).toMatchObject({
|
||||
kind: 'task',
|
||||
name: 'researcher',
|
||||
progress: ['↳ grep'],
|
||||
});
|
||||
});
|
||||
|
||||
it('drops the card once the subagent settles', () => {
|
||||
const items = foldLiveEvent(started, { type: 'task-end', id: 's1' });
|
||||
expect(items).toEqual([{ ...assistant('hi'), streaming: false }]);
|
||||
});
|
||||
|
||||
it('ignores task-end for a card that is not there', () => {
|
||||
const items = foldLiveEvent([assistant('hi')], {
|
||||
type: 'task-end',
|
||||
id: 'ghost',
|
||||
});
|
||||
expect(items).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('foldLiveEvent user (promptId/sentToModel parity)', () => {
|
||||
it('carries promptId and sentToModel onto the user item (R1-16)', () => {
|
||||
const items = foldLiveEvent([assistant('hi')], {
|
||||
@@ -1003,3 +1039,44 @@ describe('describeLegacyGoalCard (ink kind form)', () => {
|
||||
).toMatchObject({ lastCheck: undefined });
|
||||
});
|
||||
});
|
||||
|
||||
describe('foldLiveEvent assistant timestamps (#76)', () => {
|
||||
const stampOf = (items: readonly LiveHistoryItem[]): number | undefined =>
|
||||
(items[0] as LiveAssistantItem).timestamp;
|
||||
|
||||
it('opens the block at the replayed record time', () => {
|
||||
const items = foldLiveEvent([], {
|
||||
type: 'text',
|
||||
delta: 'hi',
|
||||
timestamp: 1_700_000_000_000,
|
||||
});
|
||||
expect(items[0]).toMatchObject({
|
||||
kind: 'assistant',
|
||||
text: 'hi',
|
||||
timestamp: 1_700_000_000_000,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the opening stamp as later deltas append to the same block', () => {
|
||||
let items = foldLiveEvent([], {
|
||||
type: 'text',
|
||||
delta: 'a',
|
||||
timestamp: 1_700_000_000_000,
|
||||
});
|
||||
items = foldLiveEvent(items, {
|
||||
type: 'text',
|
||||
delta: 'b',
|
||||
timestamp: 1_800_000_000_000,
|
||||
});
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0]).toMatchObject({ text: 'ab' });
|
||||
expect(stampOf(items)).toBe(1_700_000_000_000);
|
||||
});
|
||||
|
||||
it('falls back to the fold time for a live delta', () => {
|
||||
const before = Date.now();
|
||||
const items = foldLiveEvent([], { type: 'text', delta: 'live' });
|
||||
expect(stampOf(items)).toBeGreaterThanOrEqual(before);
|
||||
expect(stampOf(items)).toBeLessThanOrEqual(Date.now());
|
||||
});
|
||||
});
|
||||
@@ -13,7 +13,11 @@
|
||||
*/
|
||||
|
||||
import type { HistoryItem } from '../model/streaming-model.js';
|
||||
import type { GoalSnapshotLike, OpenTuiStreamEvent } from './event-adapter.js';
|
||||
import type {
|
||||
GoalSnapshotLike,
|
||||
OpenTuiStreamEvent,
|
||||
SubagentSummary,
|
||||
} from './event-adapter.js';
|
||||
import type { TodoItem } from '../components/TodoDisplay.js';
|
||||
import type { GoalLegacyCardData } from '../utils/goal-card-view.js';
|
||||
import type { AnsiToken } from '@qwen-code/qwen-code-core';
|
||||
@@ -44,6 +48,9 @@ export type LiveToolItem = Extract<HistoryItem, { kind: 'tool' }> & {
|
||||
totalLines?: number;
|
||||
totalBytes?: number;
|
||||
};
|
||||
/** Structured subagent summary: the card renders ink's three coloured runs
|
||||
* (SubagentScrollbackSummary parity) instead of the flattened output. */
|
||||
subagentSummary?: SubagentSummary;
|
||||
/** Vision-bridge egress disclosure (ink ToolMessage renders the notice
|
||||
* under the result): tells the user their image/prompt left the machine
|
||||
* via the vision model. */
|
||||
@@ -173,8 +180,18 @@ export type LiveGoalItem = {
|
||||
/** Fields of an ink goal_status history item (kind form). */
|
||||
export type LiveGoalLegacyData = GoalLegacyCardData;
|
||||
|
||||
export type LiveAssistantItem = Extract<HistoryItem, { kind: 'assistant' }> & {
|
||||
/** When the block opened: the record's own time on a resume replay, the fold
|
||||
* time live. Rendered by `output.showTimestamps`. */
|
||||
timestamp?: number;
|
||||
};
|
||||
|
||||
export type LiveHistoryItem =
|
||||
| Exclude<HistoryItem, { kind: 'tool' } | { kind: 'thinking' }>
|
||||
| Exclude<
|
||||
HistoryItem,
|
||||
{ kind: 'tool' } | { kind: 'thinking' } | { kind: 'assistant' }
|
||||
>
|
||||
| LiveAssistantItem
|
||||
| LiveThinkingItem
|
||||
| LiveToolItem
|
||||
| LiveImageItem
|
||||
@@ -264,6 +281,7 @@ export function foldLiveEvent(
|
||||
id: nid('as'),
|
||||
text: ev.delta,
|
||||
streaming: true,
|
||||
timestamp: ev.timestamp ?? Date.now(),
|
||||
});
|
||||
}
|
||||
return items;
|
||||
@@ -328,6 +346,7 @@ export function foldLiveEvent(
|
||||
diff: structured?.diff,
|
||||
todos: structured?.todos,
|
||||
ansi: structured?.ansi,
|
||||
subagentSummary: structured?.subagentSummary,
|
||||
};
|
||||
if (ev.type === 'tool-result' && ev.visionBridgeNotice) {
|
||||
next.visionBridgeNotice = ev.visionBridgeNotice;
|
||||
@@ -398,7 +417,6 @@ export function foldLiveEvent(
|
||||
name: ev.name,
|
||||
description: ev.description,
|
||||
progress: [],
|
||||
done: false,
|
||||
});
|
||||
return items;
|
||||
case 'task-progress': {
|
||||
@@ -410,15 +428,11 @@ export function foldLiveEvent(
|
||||
return items;
|
||||
}
|
||||
case 'task-end': {
|
||||
// ink drops the roster row the moment a subagent turns terminal; the
|
||||
// tool card's own one-line summary is the only record left, so keeping
|
||||
// this card would print the same stats twice.
|
||||
const i = items.findIndex((it) => it.kind === 'task' && it.id === ev.id);
|
||||
if (i >= 0 && items[i].kind === 'task') {
|
||||
const t = items[i] as Extract<HistoryItem, { kind: 'task' }>;
|
||||
items[i] = {
|
||||
...t,
|
||||
done: true,
|
||||
stats: `${ev.tools} tools · ${ev.seconds}s · ${ev.tokens} tokens`,
|
||||
};
|
||||
}
|
||||
if (i >= 0) items.splice(i, 1);
|
||||
return items;
|
||||
}
|
||||
case 'image': {
|
||||
|
||||
@@ -2155,9 +2155,6 @@ describe('livePromptEvents', () => {
|
||||
expect(events).toContainEqual({
|
||||
type: 'task-end',
|
||||
id: 'agent1',
|
||||
tools: 2,
|
||||
seconds: 12.4,
|
||||
tokens: '2.1k',
|
||||
});
|
||||
// Progress for already-seen subagent tool calls is not repeated.
|
||||
expect(
|
||||
|
||||
@@ -199,11 +199,6 @@ export function nextLivePromptId(config: Config): string {
|
||||
return id;
|
||||
}
|
||||
|
||||
/** Compact token count for task-end stats (matches the scripted demo form). */
|
||||
function formatTokenCount(n: number): string {
|
||||
return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n);
|
||||
}
|
||||
|
||||
/**
|
||||
* Single-consumer async queue: lets the scheduler's output callbacks enqueue
|
||||
* neutral events while the generator is awaiting tool completion, so live
|
||||
@@ -832,16 +827,7 @@ export async function* livePromptEvents(
|
||||
});
|
||||
}
|
||||
if (agent.status !== 'running' && agent.status !== 'background') {
|
||||
const stats = agent.executionSummary;
|
||||
out.push({
|
||||
type: 'task-end',
|
||||
id: callId,
|
||||
tools: stats?.totalToolCalls ?? agent.toolCalls?.length ?? 0,
|
||||
seconds: Math.round((stats?.totalDurationMs ?? 0) / 100) / 10,
|
||||
tokens: formatTokenCount(
|
||||
stats?.totalTokens ?? agent.tokenCount ?? 0,
|
||||
),
|
||||
});
|
||||
out.push({ type: 'task-end', id: callId });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -360,28 +360,32 @@ describe('message meta (ink glyph/color parity)', () => {
|
||||
});
|
||||
|
||||
it('keeps the thinking collapse hint semantics', () => {
|
||||
const live = thinkingMeta(false, false, true);
|
||||
const live = thinkingMeta(false, false);
|
||||
expect(live.icon).toBe('∵\uFE0E');
|
||||
expect(live.collapsed).toBe(false);
|
||||
const collapsed = thinkingMeta(true, false, true);
|
||||
const collapsed = thinkingMeta(true, false);
|
||||
expect(collapsed.icon).toBe('∴\uFE0E');
|
||||
expect(collapsed.hint).toContain('ctrl+o');
|
||||
// ink offers the click half only while the pointer is captured, so
|
||||
// ui.mouseTracking: false must drop it rather than advertise a dead click.
|
||||
expect(collapsed.hint).toContain('click');
|
||||
expect(thinkingMeta(true, false, undefined, false).hint).toBe(
|
||||
'(ctrl+o to expand)',
|
||||
);
|
||||
});
|
||||
|
||||
it('labels a committed thought with ink’s duration wording', () => {
|
||||
expect(thinkingMeta(true, false, false, 400).label).toBe('Thought briefly');
|
||||
expect(thinkingMeta(true, false, false, 12_000).label).toBe(
|
||||
'Thought for 12s',
|
||||
);
|
||||
expect(thinkingMeta(true, true, false, 12_000).label).toBe(
|
||||
'Thought for 12s',
|
||||
);
|
||||
expect(thinkingMeta(true, false, 400).label).toBe('Thought briefly');
|
||||
expect(thinkingMeta(true, false, 12_000).label).toBe('Thought for 12s');
|
||||
expect(thinkingMeta(true, true, 12_000).label).toBe('Thought for 12s');
|
||||
// No duration stamped: ink falls back to the pending wording rather than
|
||||
// naming a time it never measured.
|
||||
expect(thinkingMeta(true, false, false).label).toBe('Thinking');
|
||||
// The duration is only stamped when the thought ends, so a live row never
|
||||
// carries one.
|
||||
expect(thinkingMeta(false, false, false, 12_000).label).toBe('Thinking…');
|
||||
expect(thinkingMeta(true, false).label).toBe('Thinking');
|
||||
});
|
||||
|
||||
it('suffixes the live label with the elapsed time', () => {
|
||||
expect(thinkingMeta(false, false).label).toBe('Thinking…');
|
||||
expect(thinkingMeta(false, false, 12_000).label).toBe('Thinking… 12s');
|
||||
});
|
||||
|
||||
it('marks canceled tools for strikethrough', () => {
|
||||
|
||||
@@ -397,13 +397,13 @@ export function toolCardDescription(rawName: string, args?: string): string {
|
||||
}
|
||||
|
||||
export function userMessageMeta(): { glyph: string; color: string } {
|
||||
// UserMessage → PrefixedTextMessage with theme.text.accent (purple).
|
||||
return { glyph: '>', color: C.purple };
|
||||
// UserMessage → PrefixedTextMessage with theme.text.accent.
|
||||
return { glyph: '>', color: C.accent };
|
||||
}
|
||||
|
||||
export function assistantMessageMeta(): { glyph: string; color: string } {
|
||||
// AssistantMessage → ICON.DIAMOND prefix, theme.text.accent.
|
||||
return { glyph: ICON.DIAMOND, color: C.purple };
|
||||
return { glyph: ICON.DIAMOND, color: C.accent };
|
||||
}
|
||||
|
||||
/** ink ConversationMessages: under this a committed thought reads "briefly". */
|
||||
@@ -421,17 +421,15 @@ export interface ThinkingMeta {
|
||||
/**
|
||||
* ThinkMessage semantics: a live thought shows `∵ Thinking…`, a committed
|
||||
* thought collapses to `∴ Thought for … (… to expand)` unless expanded. The
|
||||
* click hint mirrors the VP-mode "click or ctrl+o" affordance.
|
||||
* collapsed hint follows ink's `clickable` branch — `ui.mouseTracking` off
|
||||
* hands the pointer back to the terminal, so only the key hint is offered.
|
||||
*/
|
||||
export function thinkingMeta(
|
||||
done: boolean,
|
||||
expanded: boolean,
|
||||
clickable: boolean,
|
||||
durationMs?: number,
|
||||
clickable = true,
|
||||
): ThinkingMeta {
|
||||
const expandHint = clickable
|
||||
? '(click or ctrl+o to expand)'
|
||||
: '(ctrl+o to expand)';
|
||||
const completedLabel =
|
||||
durationMs === undefined
|
||||
? null
|
||||
@@ -439,9 +437,14 @@ export function thinkingMeta(
|
||||
? 'Thought briefly'
|
||||
: `Thought for ${formatDuration(durationMs)}`;
|
||||
if (!done) {
|
||||
// ink appends the live elapsed time to the pending label.
|
||||
const pendingLabel =
|
||||
durationMs === undefined
|
||||
? 'Thinking…'
|
||||
: `Thinking… ${formatDuration(durationMs)}`;
|
||||
return {
|
||||
icon: ICON.BECAUSE,
|
||||
label: 'Thinking…',
|
||||
label: pendingLabel,
|
||||
hint: '',
|
||||
color: C.dim,
|
||||
collapsed: false,
|
||||
@@ -451,7 +454,7 @@ export function thinkingMeta(
|
||||
return {
|
||||
icon: ICON.THEREFORE,
|
||||
label: completedLabel ?? 'Thinking',
|
||||
hint: expandHint,
|
||||
hint: clickable ? '(click or ctrl+o to expand)' : '(ctrl+o to expand)',
|
||||
color: C.dim,
|
||||
collapsed: true,
|
||||
};
|
||||
|
||||
@@ -15,6 +15,9 @@
|
||||
* - the callbacks the mount hands a dialog do the real thing — persist through
|
||||
* the data helpers, reach the composer owner, or report a seam this shell
|
||||
* does not wire rather than closing over a no-op;
|
||||
* - a dialog outcome ink writes to the transcript reaches both the transcript
|
||||
* and the chat recording, while one ink keeps transient stays on the shell's
|
||||
* notice slot;
|
||||
* - the help request routes to HelpOverlay and drives tab/scroll keys through
|
||||
* the mount's own useKeyboard handler;
|
||||
* - an unknown dialog kind hits the never-default and throws.
|
||||
@@ -29,6 +32,8 @@ import { OpenTuiDialogMount } from './opentui-dialog-mount.js';
|
||||
import {
|
||||
addWorkspaceDirectory,
|
||||
applyModelSelection,
|
||||
applyMcpServerAction,
|
||||
applyThemeSelection,
|
||||
removeWorkspaceDirectory,
|
||||
} from './dialog-data.js';
|
||||
import type { OpenTuiDialogRequest } from './commands-registry.js';
|
||||
@@ -142,7 +147,7 @@ vi.mock('./dialog-data.js', () => ({
|
||||
removeWorkspaceDirectory: vi.fn(),
|
||||
buildMcpServers: () => [],
|
||||
enrichMcpOAuthState: async () => [],
|
||||
applyMcpServerAction: async () => ({ message: null, changed: false }),
|
||||
applyMcpServerAction: vi.fn(async () => ({ message: null, changed: false })),
|
||||
getMcpServerTools: () => [],
|
||||
getMcpServerResources: () => [],
|
||||
buildExtensionRows: () => [],
|
||||
@@ -155,7 +160,7 @@ vi.mock('./dialog-data.js', () => ({
|
||||
buildModelEntries: () => [],
|
||||
computeModelDialogInitialKey: () => undefined,
|
||||
applyModelSelection: vi.fn(async () => ({ ok: true as const })),
|
||||
applyThemeSelection: () => ({ applied: undefined, error: undefined }),
|
||||
applyThemeSelection: vi.fn(() => ({ applied: undefined, error: undefined })),
|
||||
}));
|
||||
|
||||
vi.mock('./help-overlay.js', () => ({ HelpOverlay: mocks.stub('help') }));
|
||||
@@ -567,6 +572,124 @@ describe('OpenTuiDialogMount routing', () => {
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// ink's split, ported: the outcomes it writes to the transcript must survive
|
||||
// the dialog closing, and the commands it records must replay on resume.
|
||||
const persistedOutcomes: Array<{
|
||||
request: OpenTuiDialogRequest;
|
||||
args: string[];
|
||||
rawCommand: string | null;
|
||||
type: string;
|
||||
}> = [
|
||||
{
|
||||
request: { dialog: 'editor' },
|
||||
args: ['Editor set to vim.'],
|
||||
rawCommand: '/editor',
|
||||
type: 'info',
|
||||
},
|
||||
{
|
||||
request: { dialog: 'effort' },
|
||||
args: ['Reasoning effort: high (requested; clamped per model).'],
|
||||
rawCommand: '/effort',
|
||||
type: 'info',
|
||||
},
|
||||
{
|
||||
request: { dialog: 'output-style' },
|
||||
args: ['Failed to set "general.outputStyle": disk full', 'error'],
|
||||
rawCommand: '/output-style',
|
||||
type: 'error',
|
||||
},
|
||||
{
|
||||
// /delete is in SLASH_COMMANDS_SKIP_RECORDING: added, not recorded.
|
||||
request: { dialog: 'delete' },
|
||||
args: ['Failed to delete session.', 'error'],
|
||||
rawCommand: null,
|
||||
type: 'error',
|
||||
},
|
||||
{
|
||||
request: { dialog: 'auth', openedViaCommand: true },
|
||||
args: ['Authenticated with Qwen OAuth.'],
|
||||
rawCommand: '/auth',
|
||||
type: 'info',
|
||||
},
|
||||
{
|
||||
// The boot auth-error open is not a command, so ink records nothing.
|
||||
request: { dialog: 'auth', initialError: 'Token expired.' },
|
||||
args: ['Authenticated with Qwen OAuth.'],
|
||||
rawCommand: null,
|
||||
type: 'info',
|
||||
},
|
||||
{
|
||||
request: { dialog: 'arena', mode: 'select' },
|
||||
args: ['Arena session started.'],
|
||||
rawCommand: '/arena select',
|
||||
type: 'info',
|
||||
},
|
||||
{
|
||||
request: { dialog: 'arena', mode: 'stop' },
|
||||
args: ['No arena session is running.', 'error'],
|
||||
rawCommand: '/arena stop',
|
||||
type: 'error',
|
||||
},
|
||||
];
|
||||
|
||||
it.each(persistedOutcomes)(
|
||||
'%# writes the outcome ink persists for $request.dialog',
|
||||
({ request, args, rawCommand, type }) => {
|
||||
mount(request);
|
||||
dialogProp(request.dialog, 'notify')(...args);
|
||||
|
||||
const text = args[0]!;
|
||||
expect(addItem).toHaveBeenCalledTimes(1);
|
||||
expect(addItem.mock.calls[0]![0]).toEqual({ type, text });
|
||||
if (rawCommand) {
|
||||
expect(recordSlashCommand).toHaveBeenCalledWith({
|
||||
phase: 'result',
|
||||
rawCommand,
|
||||
outputHistoryItems: [{ type, text }],
|
||||
});
|
||||
} else {
|
||||
expect(recordSlashCommand).not.toHaveBeenCalled();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it('keeps the theme, mcp and arena-start outcomes off the transcript', async () => {
|
||||
const notices: string[] = [];
|
||||
const notify = (text: string) => notices.push(text);
|
||||
|
||||
// ink reports these inside the dialog or through addItem-free paths, so a
|
||||
// transcript row here would outlive state the user can still change.
|
||||
vi.mocked(applyThemeSelection).mockReturnValueOnce({
|
||||
applied: 'Ayu',
|
||||
error: undefined,
|
||||
});
|
||||
const theme = mount({ dialog: 'theme' }, { notify });
|
||||
dialogProp('theme', 'onSelect')('Ayu', 'user');
|
||||
theme.unmount();
|
||||
|
||||
vi.mocked(applyMcpServerAction).mockResolvedValueOnce({
|
||||
message: 'Auth started.',
|
||||
changed: false,
|
||||
});
|
||||
const mcp = mount({ dialog: 'mcp' }, { notify });
|
||||
await act(async () => {
|
||||
dialogProp('mcp', 'onServerAction')({ name: 'srv' }, 'auth');
|
||||
});
|
||||
mcp.unmount();
|
||||
|
||||
const arena = mount({ dialog: 'arena', mode: 'start' }, { notify });
|
||||
dialogProp('arena', 'notify')('The arena session is already running.');
|
||||
arena.unmount();
|
||||
|
||||
expect(notices).toEqual([
|
||||
'Theme set to Ayu.',
|
||||
'Auth started.',
|
||||
'The arena session is already running.',
|
||||
]);
|
||||
expect(addItem).not.toHaveBeenCalled();
|
||||
expect(recordSlashCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws for an unhandled dialog kind', () => {
|
||||
const bad = { dialog: 'nope' } as unknown as OpenTuiDialogRequest;
|
||||
expect(() => mount(bad)).toThrow(/Unhandled OpenTUI dialog request/);
|
||||
|
||||
@@ -35,8 +35,11 @@ import type { OpenTuiDialogRequest } from './commands-registry.js';
|
||||
import type { OpenTuiAppHost } from './opentui-host.js';
|
||||
import { toOriginalKey } from './key-map.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import type { HistoryItemInfo } from '../types.js';
|
||||
import { MessageType } from '../types.js';
|
||||
import {
|
||||
MessageType,
|
||||
type HistoryItemError,
|
||||
type HistoryItemInfo,
|
||||
} from '../types.js';
|
||||
import { HelpOverlay } from './help-overlay.js';
|
||||
import {
|
||||
buildHelpCommandsLines,
|
||||
@@ -138,6 +141,31 @@ export function OpenTuiDialogMount(props: OpenTuiDialogMountProps) {
|
||||
const isHelp = request.dialog === 'help';
|
||||
const dimensions = useTerminalDimensions();
|
||||
|
||||
/**
|
||||
* ink writes these dialog outcomes to the transcript, and for the commands
|
||||
* whose result phase closes while the dialog is still open it records the
|
||||
* same row, so nothing else would replay on resume. The shell's notify slot
|
||||
* is transient and closes with the dialog. A null `rawCommand` is a command
|
||||
* ink keeps out of the recording (`SLASH_COMMANDS_SKIP_RECORDING`).
|
||||
*/
|
||||
const reportResult = (
|
||||
rawCommand: string | null,
|
||||
text: string,
|
||||
level: 'info' | 'error' = 'info',
|
||||
) => {
|
||||
const item: HistoryItemInfo | HistoryItemError =
|
||||
level === 'error'
|
||||
? { type: MessageType.ERROR, text }
|
||||
: { type: MessageType.INFO, text };
|
||||
host.addItem(item, Date.now());
|
||||
if (!rawCommand) return;
|
||||
config.getChatRecordingService?.()?.recordSlashCommand({
|
||||
phase: 'result',
|
||||
rawCommand,
|
||||
outputHistoryItems: [{ ...item }],
|
||||
});
|
||||
};
|
||||
|
||||
// --- permission rules (rebuild after add/delete) ------------------------
|
||||
const [permissions, setPermissions] = useState(() =>
|
||||
request.dialog === 'permissions' ? buildPermissionsData(config) : null,
|
||||
@@ -257,7 +285,15 @@ export function OpenTuiDialogMount(props: OpenTuiDialogMountProps) {
|
||||
const modelSelectionInFlightRef = useRef(false);
|
||||
|
||||
// --- help overlay interaction -------------------------------------------
|
||||
const [helpTab, setHelpTab] = useState<HelpTab>('general');
|
||||
const [helpTab, setHelpTabState] = useState<HelpTab>('general');
|
||||
// A held Tab hands the whole burst to the handler from the last render, so
|
||||
// the cycle must read where the previous key of the burst landed.
|
||||
const helpTabRef = useRef(helpTab);
|
||||
helpTabRef.current = helpTab;
|
||||
const setHelpTab = (next: HelpTab) => {
|
||||
helpTabRef.current = next;
|
||||
setHelpTabState(next);
|
||||
};
|
||||
const [helpScroll, setHelpScroll] = useState(0);
|
||||
const dialogWidth = dialogAreaWidth(dimensions.width);
|
||||
const helpBodyRows = computeHelpBodyRows(dimensions.height);
|
||||
@@ -283,7 +319,9 @@ export function OpenTuiDialogMount(props: OpenTuiDialogMountProps) {
|
||||
return;
|
||||
}
|
||||
const tabCount = HELP_TABS.length;
|
||||
const activeIndex = HELP_TABS.findIndex((entry) => entry.tab === helpTab);
|
||||
const activeIndex = HELP_TABS.findIndex(
|
||||
(entry) => entry.tab === helpTabRef.current,
|
||||
);
|
||||
if (name === 'tab') {
|
||||
const step = shift ? -1 : 1;
|
||||
setHelpTab(HELP_TABS[(activeIndex + step + tabCount) % tabCount]!.tab);
|
||||
@@ -374,7 +412,9 @@ export function OpenTuiDialogMount(props: OpenTuiDialogMountProps) {
|
||||
settings={settings}
|
||||
initialError={request.initialError}
|
||||
onClose={onClose}
|
||||
notify={notify}
|
||||
notify={(text) =>
|
||||
reportResult(request.openedViaCommand ? '/auth' : null, text)
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -384,7 +424,7 @@ export function OpenTuiDialogMount(props: OpenTuiDialogMountProps) {
|
||||
config={config}
|
||||
settings={settings}
|
||||
onClose={onClose}
|
||||
notify={notify}
|
||||
notify={(text) => reportResult('/editor', text)}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -444,6 +484,7 @@ export function OpenTuiDialogMount(props: OpenTuiDialogMountProps) {
|
||||
config={config}
|
||||
settings={settings}
|
||||
onClose={onClose}
|
||||
notify={(text, level) => reportResult('/effort', text, level)}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -453,7 +494,7 @@ export function OpenTuiDialogMount(props: OpenTuiDialogMountProps) {
|
||||
config={config}
|
||||
settings={settings}
|
||||
onClose={onClose}
|
||||
notify={notify}
|
||||
notify={(text, level) => reportResult('/output-style', text, level)}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -463,7 +504,7 @@ export function OpenTuiDialogMount(props: OpenTuiDialogMountProps) {
|
||||
config={config}
|
||||
settings={settings}
|
||||
onClose={onClose}
|
||||
notify={notify}
|
||||
notify={(text, level) => reportResult(null, text, level)}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -478,7 +519,14 @@ export function OpenTuiDialogMount(props: OpenTuiDialogMountProps) {
|
||||
}
|
||||
onSelect={(sessionId) => {
|
||||
void host.handleResume(sessionId).catch((error: unknown) => {
|
||||
notify(error instanceof Error ? error.message : String(error));
|
||||
// handleResumeSession reports the failures it handles as
|
||||
// transcript rows itself; this only catches a throw from outside
|
||||
// its try, where no row was written.
|
||||
reportResult(
|
||||
null,
|
||||
error instanceof Error ? error.message : String(error),
|
||||
'error',
|
||||
);
|
||||
});
|
||||
onClose();
|
||||
}}
|
||||
@@ -558,7 +606,12 @@ export function OpenTuiDialogMount(props: OpenTuiDialogMountProps) {
|
||||
config={config}
|
||||
mode={request.mode}
|
||||
onClose={onClose}
|
||||
notify={notify}
|
||||
notify={
|
||||
request.mode === 'select' || request.mode === 'stop'
|
||||
? (text, level) =>
|
||||
reportResult(`/arena ${request.mode}`, text, level)
|
||||
: notify
|
||||
}
|
||||
// Enter in the model picker starts an arena session by writing the
|
||||
// command into the composer, which the entry layer owns. Without that
|
||||
// owner the selection would vanish behind a closed dialog, so say so.
|
||||
@@ -597,21 +650,7 @@ export function OpenTuiDialogMount(props: OpenTuiDialogMountProps) {
|
||||
entries,
|
||||
mode: request.mode,
|
||||
});
|
||||
// ink writes all three model outcomes to the transcript — a pick, an
|
||||
// escape, an auxiliary pick — so the row outlives the dialog. The shell's
|
||||
// notify slot is transient and closes with it.
|
||||
const reportModel = (text: string) => {
|
||||
const item: HistoryItemInfo = { type: MessageType.INFO, text };
|
||||
host.addItem(item, Date.now());
|
||||
// ink ModelDialog records the same row it adds: the dispatcher's result
|
||||
// phase closes while the dialog is still open, so nothing else here
|
||||
// would replay on resume.
|
||||
config.getChatRecordingService?.()?.recordSlashCommand({
|
||||
phase: 'result',
|
||||
rawCommand: '/model',
|
||||
outputHistoryItems: [{ ...item }],
|
||||
});
|
||||
};
|
||||
const reportModel = (text: string) => reportResult('/model', text);
|
||||
return (
|
||||
<OpenTuiModelDialog
|
||||
entries={entries}
|
||||
|
||||
@@ -39,9 +39,16 @@ const mocks = vi.hoisted(() => {
|
||||
const config = key === undefined ? props : { ...props, key };
|
||||
const children = (config?.children ?? null) as React.ReactNode;
|
||||
if (type === 'box' || type === 'text') {
|
||||
// jsdom drops unknown props, so the colour-bearing opentui attributes
|
||||
// are surfaced as data-* for assertions to pin.
|
||||
const dom: Record<string, unknown> = key === undefined ? {} : { key };
|
||||
const source = (config ?? {}) as Record<string, unknown>;
|
||||
for (const name of ['fg', 'bg', 'borderColor', 'attributes']) {
|
||||
if (source[name] !== undefined) dom[`data-${name}`] = source[name];
|
||||
}
|
||||
return React.createElement(
|
||||
type === 'box' ? 'div' : 'span',
|
||||
key === undefined ? null : { key },
|
||||
dom,
|
||||
children,
|
||||
);
|
||||
}
|
||||
@@ -99,6 +106,11 @@ import {
|
||||
} from '../constants.js';
|
||||
import { WITTY_LOADING_PHRASES } from '../hooks/usePhraseCycler.js';
|
||||
import { OpenTuiFooter, OpenTuiLoadingIndicator } from './opentui-footer.js';
|
||||
import { C } from './theme.js';
|
||||
|
||||
function renderedSpans(container: HTMLElement): HTMLElement[] {
|
||||
return [...container.querySelectorAll('span')];
|
||||
}
|
||||
|
||||
/** The indicator's first text cell is the spinner's own 2-column box. */
|
||||
function spinnerCell(container: HTMLElement): string {
|
||||
@@ -108,7 +120,7 @@ function spinnerCell(container: HTMLElement): string {
|
||||
function fakeConfig(overrides: Partial<Config> = {}): Config {
|
||||
return {
|
||||
getTargetDir: () => '/home/user/projects/qwen-code',
|
||||
getModel: () => 'qwen3-coder-plus',
|
||||
getModelDisplayName: () => 'qwen3-coder-plus',
|
||||
getContentGeneratorConfig: () => ({ contextWindowSize: 1_000_000 }),
|
||||
...overrides,
|
||||
} as unknown as Config;
|
||||
@@ -312,6 +324,34 @@ describe('OpenTuiFooter', () => {
|
||||
expect(text).toContain('git:(main)');
|
||||
});
|
||||
|
||||
it("carries the reasoning effort on the model segment, as ink's preset does", () => {
|
||||
const withEffort = fakeConfig({
|
||||
getContentGeneratorConfig: () =>
|
||||
({
|
||||
contextWindowSize: 1_000_000,
|
||||
reasoning: { effort: 'high' },
|
||||
}) as unknown as ReturnType<Config['getContentGeneratorConfig']>,
|
||||
});
|
||||
const { container } = render(
|
||||
<OpenTuiFooter config={withEffort} streaming={false} />,
|
||||
);
|
||||
expect(container.textContent).toContain('qwen3-coder-plus high');
|
||||
|
||||
const thinkingOff = fakeConfig({
|
||||
getContentGeneratorConfig: () =>
|
||||
({
|
||||
contextWindowSize: 1_000_000,
|
||||
reasoning: false,
|
||||
}) as unknown as ReturnType<Config['getContentGeneratorConfig']>,
|
||||
});
|
||||
const off = render(
|
||||
<OpenTuiFooter config={thinkingOff} streaming={false} />,
|
||||
);
|
||||
expect(off.container.textContent).toContain(
|
||||
'qwen3-coder-plus reasoning off',
|
||||
);
|
||||
});
|
||||
|
||||
it('omits the git segment outside a repository', () => {
|
||||
mocks.state.gitBranch = undefined;
|
||||
const { container } = render(
|
||||
@@ -355,6 +395,43 @@ describe('OpenTuiFooter', () => {
|
||||
expect(container.textContent).toContain('YOLO mode');
|
||||
});
|
||||
|
||||
it('paints the status row with the accent colour ink’s Footer uses', () => {
|
||||
const { container } = render(
|
||||
<OpenTuiFooter config={fakeConfig()} streaming={false} />,
|
||||
);
|
||||
const status = renderedSpans(container).filter((row) =>
|
||||
(row.textContent ?? '').includes('qwen3-coder-plus'),
|
||||
);
|
||||
expect(status).toHaveLength(1);
|
||||
expect(status[0].getAttribute('data-fg')).toBe(C.accent);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[ApprovalMode.PLAN, 'plan mode', C.green],
|
||||
[ApprovalMode.AUTO_EDIT, 'auto-accept edits', C.yellow],
|
||||
[ApprovalMode.AUTO, 'Auto mode', C.purple],
|
||||
[ApprovalMode.YOLO, 'YOLO mode', C.red],
|
||||
[ApprovalMode.DEFAULT, '⏸ Ask permissions', C.dim],
|
||||
] as const)(
|
||||
'colours the %s label with ink’s indicator colour and keeps the shortcut secondary',
|
||||
(mode, label, color) => {
|
||||
const { container } = render(
|
||||
<OpenTuiFooter
|
||||
config={fakeConfig()}
|
||||
streaming={false}
|
||||
approvalMode={mode}
|
||||
/>,
|
||||
);
|
||||
const shortcut = renderedSpans(container).find((row) =>
|
||||
(row.textContent ?? '').includes('to cycle'),
|
||||
);
|
||||
expect(shortcut?.getAttribute('data-fg')).toBe(C.dim);
|
||||
const modeLabel = shortcut?.previousElementSibling as HTMLElement;
|
||||
expect(modeLabel.textContent).toBe(label);
|
||||
expect(modeLabel.getAttribute('data-fg')).toBe(color);
|
||||
},
|
||||
);
|
||||
|
||||
it('prefixes the default mode with ink’s pause glyph', () => {
|
||||
const { container } = render(
|
||||
<OpenTuiFooter
|
||||
@@ -404,6 +481,27 @@ describe('OpenTuiFooter', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('truncates the hint row part by part inside one line', () => {
|
||||
mocks.state.dimensions = { width: 44, height: 40 };
|
||||
const { container } = render(
|
||||
<OpenTuiFooter
|
||||
config={fakeConfig()}
|
||||
streaming
|
||||
queueLength={2}
|
||||
approvalMode={ApprovalMode.AUTO}
|
||||
/>,
|
||||
);
|
||||
// The hint row is the nested row box; the status line is a sibling span.
|
||||
const hint = container.querySelector('div > div > div')?.textContent ?? '';
|
||||
expect(hint).toContain('Enter to steer');
|
||||
// The coloured mode label survives narrowing instead of being dropped
|
||||
// whole, but the budget is spent: nothing after it is rendered.
|
||||
expect(hint).toContain('Auto');
|
||||
expect(hint).not.toContain('shift + tab');
|
||||
expect(hint).not.toContain('queued');
|
||||
expect(hint.length).toBeLessThanOrEqual(40);
|
||||
});
|
||||
|
||||
it('gives shell mode the hint slot ink’s ShellModeIndicator holds', () => {
|
||||
const { container } = render(
|
||||
<OpenTuiFooter
|
||||
|
||||
@@ -12,8 +12,9 @@
|
||||
* `feat/opentui-migrate` implementation the batched merge dropped.
|
||||
*
|
||||
* The mode segment is labelled by `formatApprovalModeName`, the mapping the rest
|
||||
* of the UI already uses, and stays dim: the composer carries the mode's colour
|
||||
* on its prefix glyph and border.
|
||||
* of the UI already uses, and carries the mode's indicator colour on the label
|
||||
* while its cycle shortcut stays secondary — the split ink's
|
||||
* `AutoAcceptIndicator` renders.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState, type RefObject } from 'react';
|
||||
@@ -37,6 +38,8 @@ import { useTimer } from '../hooks/useTimer.js';
|
||||
import { useAnimationFrame } from '../hooks/useAnimationFrame.js';
|
||||
import { fmtTokens } from '../components/stats-helpers.js';
|
||||
import { formatApprovalModeName } from '../utils/approvalModeDisplay.js';
|
||||
import { formatModelWithReasoning } from '../statusLinePresets.js';
|
||||
import { getReasoningForDisplay } from '../../acp-integration/model-configuration.js';
|
||||
import {
|
||||
contextUsageLabel,
|
||||
formatDuration,
|
||||
@@ -47,6 +50,32 @@ import { isNarrowWidth } from '../utils/isNarrowWidth.js';
|
||||
import { getCachedStringWidth, truncateToWidth } from '../utils/textUtils.js';
|
||||
import { C } from './theme.js';
|
||||
|
||||
/** One coloured run of the footer's bottom hint row. */
|
||||
interface HintPart {
|
||||
text: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* ink's `getApprovalModeIndicatorColor` (components/approvalModeVisuals.ts),
|
||||
* read off the mapped palette instead of the ink theme: ink returns raw theme
|
||||
* strings, and a CSS colour name opentui cannot parse degrades to magenta.
|
||||
*/
|
||||
function approvalModeColor(mode: ApprovalMode): string {
|
||||
switch (mode) {
|
||||
case ApprovalMode.PLAN:
|
||||
return C.green;
|
||||
case ApprovalMode.AUTO_EDIT:
|
||||
return C.yellow;
|
||||
case ApprovalMode.AUTO:
|
||||
return C.purple;
|
||||
case ApprovalMode.YOLO:
|
||||
return C.red;
|
||||
default:
|
||||
return C.dim;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns its frame timer so the high-frequency tick re-renders ONLY this 1-cell
|
||||
* component, not the whole transcript tree. ink's `RespondingSpinner` animates
|
||||
@@ -184,10 +213,15 @@ export function OpenTuiFooter({
|
||||
const { width } = useTerminalDimensions();
|
||||
const targetDir = config.getTargetDir();
|
||||
const gitBranch = useGitBranchName(targetDir) ?? '';
|
||||
const footerModel = config.getModel();
|
||||
const generationConfig = config.getContentGeneratorConfig();
|
||||
// ink's status line renders the `model-with-reasoning` preset item, so the
|
||||
// segment carries the effort (or `reasoning off`), not the bare model id.
|
||||
const footerModel = formatModelWithReasoning(
|
||||
config.getModelDisplayName(),
|
||||
generationConfig && getReasoningForDisplay(config, generationConfig),
|
||||
);
|
||||
const promptTokenCount = uiTelemetryService.getLastPromptTokenCount();
|
||||
const contextWindowSize =
|
||||
config.getContentGeneratorConfig()?.contextWindowSize;
|
||||
const contextWindowSize = generationConfig?.contextWindowSize;
|
||||
// Original status-line parity: the context indicator only appears once tokens
|
||||
// have been used, never bare.
|
||||
const contextLabel =
|
||||
@@ -202,34 +236,52 @@ export function OpenTuiFooter({
|
||||
(gitBranch ? ` · git:(${gitBranch})` : '') +
|
||||
(footerModel ? ` · ${footerModel}` : '') +
|
||||
contextLabel;
|
||||
// ink's AutoAcceptIndicator prefixes the default mode with a pause glyph and
|
||||
// suffixes the cycle shortcut; formatApprovalModeName is shared with the
|
||||
// dialogs and carries neither. Windows gets the bare-Tab wording because
|
||||
// some terminals there cannot tell Shift+Tab from Tab.
|
||||
// ink's AutoAcceptIndicator prefixes the default mode with a pause glyph,
|
||||
// colours the label with the mode's indicator colour and keeps the cycle
|
||||
// shortcut in text.secondary; formatApprovalModeName is shared with the
|
||||
// dialogs and carries none of the three. Windows gets the bare-Tab wording
|
||||
// because some terminals there cannot tell Shift+Tab from Tab.
|
||||
const cycleText =
|
||||
process.platform === 'win32'
|
||||
? t('(tab to cycle)')
|
||||
: t('(shift + tab to cycle)');
|
||||
const pausePrefix = approvalMode === ApprovalMode.DEFAULT ? '⏸ ' : '';
|
||||
const modeLabel = approvalMode
|
||||
? `${pausePrefix}${formatApprovalModeName(approvalMode)} ${cycleText}`
|
||||
: null;
|
||||
const modeHint = shellModeActive
|
||||
? 'shell mode enabled (esc to disable)'
|
||||
: [streaming ? t('Enter to steer · Ctrl+Q to queue') : null, modeLabel]
|
||||
.filter((segment): segment is string => segment !== null)
|
||||
.join(' · ');
|
||||
const modeHint: HintPart[] = shellModeActive
|
||||
? [{ text: 'shell mode enabled (esc to disable)', color: C.dim }]
|
||||
: [
|
||||
...(streaming
|
||||
? [
|
||||
{ text: t('Enter to steer · Ctrl+Q to queue'), color: C.dim },
|
||||
// ink's leftBottomContent puts ' · ' between its own segments,
|
||||
// but only where two of them are actually present.
|
||||
...(approvalMode ? [{ text: ' · ', color: C.dim }] : []),
|
||||
]
|
||||
: []),
|
||||
...(approvalMode
|
||||
? [
|
||||
{
|
||||
text: `${pausePrefix}${formatApprovalModeName(approvalMode)}`,
|
||||
color: approvalModeColor(approvalMode),
|
||||
},
|
||||
{ text: ` ${cycleText}`, color: C.dim },
|
||||
]
|
||||
: []),
|
||||
];
|
||||
const queuedHint =
|
||||
queueLength > 0
|
||||
? `⏳ ${t('{{count}} queued', { count: String(queueLength) })}`
|
||||
: null;
|
||||
const hintSegments = [modeHint || null, queuedHint].filter(
|
||||
(segment): segment is string => segment !== null,
|
||||
);
|
||||
// ink renders the badge as a sibling text node beginning with a literal
|
||||
// space, so it joins the hint with one space — unlike the ' · ' that ink's
|
||||
// leftBottomContent puts between its own segments.
|
||||
const footerLine2 = hintSegments.join(' ');
|
||||
// space, so it joins the hint with one space.
|
||||
const hintParts: HintPart[] = [
|
||||
...modeHint,
|
||||
...(queuedHint
|
||||
? [
|
||||
...(modeHint.length > 0 ? [{ text: ' ', color: C.dim }] : []),
|
||||
{ text: queuedHint, color: C.dim },
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
// ink renders these two rows under different wrap policies: the status line
|
||||
// is `wrap="wrap"` inside a two-line `overflow="hidden"` box, so a narrow
|
||||
@@ -241,6 +293,17 @@ export function OpenTuiFooter({
|
||||
trim: false,
|
||||
hard: true,
|
||||
}).split('\n');
|
||||
// One truncated line in ink; cut part by part against the remaining budget so
|
||||
// the coloured mode label survives narrowing instead of being dropped whole.
|
||||
const hintRow: HintPart[] = [];
|
||||
let hintBudget = rowBudget;
|
||||
for (const part of hintParts) {
|
||||
if (hintBudget <= 0) break;
|
||||
const text = truncateToWidth(part.text, hintBudget);
|
||||
if (text) hintRow.push({ text, color: part.color });
|
||||
hintBudget -= getCachedStringWidth(text);
|
||||
if (text.length !== part.text.length) break;
|
||||
}
|
||||
|
||||
// ink gives the armed quit warning the footer's bottom hint slot and gates
|
||||
// its status line off while the warning is up, so the warning reads directly
|
||||
@@ -264,10 +327,19 @@ export function OpenTuiFooter({
|
||||
|
||||
return (
|
||||
<box flexDirection="column" paddingLeft={2} paddingRight={2} flexShrink={0}>
|
||||
<text fg={C.dim}>{statusLines[0]}</text>
|
||||
{statusLines[1] && <text fg={C.dim}>{statusLines[1]}</text>}
|
||||
{footerLine2 && (
|
||||
<text fg={C.dim}>{truncateToWidth(footerLine2, rowBudget)}</text>
|
||||
{/* ink colours the built-in status line with text.accent (Footer.tsx);
|
||||
* a user-supplied statusline keeps its own colours, which this port does
|
||||
* not render at all. */}
|
||||
<text fg={C.accent}>{statusLines[0]}</text>
|
||||
{statusLines[1] && <text fg={C.accent}>{statusLines[1]}</text>}
|
||||
{hintRow.length > 0 && (
|
||||
<box flexDirection="row">
|
||||
{hintRow.map((part, i) => (
|
||||
<text key={`${i}`} fg={part.color}>
|
||||
{part.text}
|
||||
</text>
|
||||
))}
|
||||
</box>
|
||||
)}
|
||||
</box>
|
||||
);
|
||||
|
||||
@@ -33,9 +33,16 @@ const mocks = vi.hoisted(() => {
|
||||
const config = key === undefined ? props : { ...props, key };
|
||||
const children = (config?.children ?? null) as React.ReactNode;
|
||||
if (type === 'box' || type === 'text') {
|
||||
// jsdom drops unknown props, so the colour-bearing opentui attributes
|
||||
// are surfaced as data-* for assertions to pin.
|
||||
const dom: Record<string, unknown> = key === undefined ? {} : { key };
|
||||
const source = (config ?? {}) as Record<string, unknown>;
|
||||
for (const name of ['fg', 'bg', 'borderColor', 'attributes']) {
|
||||
if (source[name] !== undefined) dom[`data-${name}`] = source[name];
|
||||
}
|
||||
return React.createElement(
|
||||
type === 'box' ? 'div' : 'span',
|
||||
key === undefined ? null : { key },
|
||||
dom,
|
||||
children,
|
||||
);
|
||||
}
|
||||
@@ -59,6 +66,22 @@ vi.mock('@opentui/react/jsx-dev-runtime', () => mocks.buildJsxRuntime());
|
||||
import type { Config } from '@qwen-code/qwen-code-core';
|
||||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
import { OpenTuiBanner } from './opentui-header.js';
|
||||
import { getActiveOpenTuiTheme, getOpenTuiTheme } from './theme-parity.js';
|
||||
import { C, applyOpenTuiTheme, applyThemeMode } from './theme.js';
|
||||
|
||||
/** Per-wordmark-row foregrounds, in column order. */
|
||||
function logoRows(container: HTMLElement): Array<Array<string | null>> {
|
||||
const panel = container.querySelector('[data-bordercolor]');
|
||||
const logo = panel?.parentElement?.children[0];
|
||||
return (
|
||||
[...(logo?.children ?? [])]
|
||||
.map((row) =>
|
||||
[...row.children].map((cell) => cell.getAttribute('data-fg')),
|
||||
)
|
||||
// The bundled logo string ends with a newline, which leaves one empty row.
|
||||
.filter((row) => row.length > 0)
|
||||
);
|
||||
}
|
||||
|
||||
function fakeConfig(overrides: Partial<Config> = {}): Config {
|
||||
return {
|
||||
@@ -93,6 +116,15 @@ describe('OpenTuiBanner', () => {
|
||||
expect(text).toContain('qwen-code');
|
||||
});
|
||||
|
||||
it('outlines the info panel with the palette’s default border colour', () => {
|
||||
const { container } = render(
|
||||
<OpenTuiBanner config={fakeConfig()} settings={fakeSettings()} />,
|
||||
);
|
||||
const panel = container.querySelector('[data-bordercolor]');
|
||||
expect(panel).not.toBeNull();
|
||||
expect(panel?.getAttribute('data-bordercolor')).toBe(C.borderDefault);
|
||||
});
|
||||
|
||||
it('renders the version reported by the config with a v prefix', () => {
|
||||
const { container } = render(
|
||||
<OpenTuiBanner config={fakeConfig()} settings={fakeSettings()} />,
|
||||
@@ -146,3 +178,102 @@ describe('OpenTuiBanner', () => {
|
||||
expect(container.textContent).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Expected foregrounds read off the ink truecolor capture of this frame
|
||||
* (s01-boot-banner/ink/boot.styled.txt), where the wordmark is 36,37,37,37,37,37
|
||||
* columns wide and `ink-gradient` samples a fresh ramp per laid-out line.
|
||||
*/
|
||||
describe('wordmark gradient', () => {
|
||||
it('samples each line on its own ramp, as ink’s per-line Transform does', () => {
|
||||
const { container } = render(
|
||||
<OpenTuiBanner config={fakeConfig()} settings={fakeSettings()} />,
|
||||
);
|
||||
const rows = logoRows(container);
|
||||
expect(rows.map((row) => row.length)).toEqual([36, 37, 37, 37, 37, 37]);
|
||||
|
||||
// The 36-column line rebalances its two segments to 17/18, so its middle
|
||||
// stop lands at index 17; the 37-column lines balance to 18/18 and land at
|
||||
// 18. Index 1 therefore differs between the two — a single shared ramp
|
||||
// cannot produce both.
|
||||
expect(rows[0]!.slice(0, 4)).toEqual([
|
||||
'#4796e4',
|
||||
'#4b94e3',
|
||||
'#4e93e1',
|
||||
'#5291e0',
|
||||
]);
|
||||
expect(rows[0]![17]).toBe('#847ace');
|
||||
expect(rows[1]!.slice(0, 4)).toEqual([
|
||||
'#4796e4',
|
||||
'#4a94e3',
|
||||
'#4e93e2',
|
||||
'#5191e0',
|
||||
]);
|
||||
expect(rows[1]![18]).toBe('#847ace');
|
||||
});
|
||||
|
||||
it('reaches the last stop on every line, including the shorter first one', () => {
|
||||
const { container } = render(
|
||||
<OpenTuiBanner config={fakeConfig()} settings={fakeSettings()} />,
|
||||
);
|
||||
for (const row of logoRows(container)) {
|
||||
expect(row.at(-1)).toBe('#c3677f');
|
||||
}
|
||||
});
|
||||
|
||||
it('follows the active theme’s own ramp', () => {
|
||||
applyOpenTuiTheme(getOpenTuiTheme('Dracula')!);
|
||||
try {
|
||||
const { container } = render(
|
||||
<OpenTuiBanner config={fakeConfig()} settings={fakeSettings()} />,
|
||||
);
|
||||
for (const row of logoRows(container)) {
|
||||
expect(row[0]).toBe('#ff79c6');
|
||||
expect(row.at(-1)).toBe('#8be9fd');
|
||||
}
|
||||
} finally {
|
||||
applyThemeMode('dark');
|
||||
}
|
||||
});
|
||||
|
||||
it('repaints a mounted banner when the theme changes', () => {
|
||||
// `/theme` mutates this LoadedSettings in place and leaves the Config
|
||||
// alone, so no other memo dep changes identity under a live banner.
|
||||
const config = fakeConfig();
|
||||
const settings = fakeSettings();
|
||||
const { container, rerender } = render(
|
||||
<OpenTuiBanner config={config} settings={settings} />,
|
||||
);
|
||||
expect(logoRows(container)[0]![0]).toBe('#4796e4');
|
||||
|
||||
applyOpenTuiTheme(getOpenTuiTheme('Dracula')!);
|
||||
try {
|
||||
rerender(<OpenTuiBanner config={config} settings={settings} />);
|
||||
expect(logoRows(container)[0]![0]).toBe('#ff79c6');
|
||||
} finally {
|
||||
applyThemeMode('dark');
|
||||
}
|
||||
});
|
||||
|
||||
it('leaves the logo uncolored when the theme has no usable ramp', () => {
|
||||
const previous = process.env['NO_COLOR'];
|
||||
process.env['NO_COLOR'] = '1';
|
||||
applyOpenTuiTheme(getActiveOpenTuiTheme());
|
||||
try {
|
||||
const { container } = render(
|
||||
<OpenTuiBanner config={fakeConfig()} settings={fakeSettings()} />,
|
||||
);
|
||||
const rows = logoRows(container);
|
||||
expect(rows).toHaveLength(6);
|
||||
for (const row of rows) {
|
||||
for (const cell of row) {
|
||||
expect(cell).toBeNull();
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env['NO_COLOR'];
|
||||
else process.env['NO_COLOR'] = previous;
|
||||
applyThemeMode('dark');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
import { formatVersionLabel } from '../../utils/version.js';
|
||||
import { C } from './theme.js';
|
||||
import { C, GRADIENT, THEME_REVISION } from './theme.js';
|
||||
import { shortAsciiLogo } from '../components/AsciiArt.js';
|
||||
import { getAsciiArtWidth, getCachedStringWidth } from '../utils/textUtils.js';
|
||||
import {
|
||||
@@ -37,46 +37,76 @@ import {
|
||||
resolveCustomBanner,
|
||||
} from '../utils/customBanner.js';
|
||||
|
||||
const LOGO_GRADIENT = ['#4796E4', '#847ACE', '#C3677F'];
|
||||
function hexToRgb(hex: string): [number, number, number] {
|
||||
const n = parseInt(hex.slice(1), 16);
|
||||
return [(n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff];
|
||||
}
|
||||
|
||||
function lerpHex(a: string, b: string, t: number): string {
|
||||
const pa = [1, 3, 5].map((i) => parseInt(a.slice(i, i + 2), 16));
|
||||
const pb = [1, 3, 5].map((i) => parseInt(b.slice(i, i + 2), 16));
|
||||
function rgbToHex(rgb: readonly number[]): string {
|
||||
return (
|
||||
'#' +
|
||||
pa
|
||||
.map((v, i) =>
|
||||
Math.round(v + (pb[i] - v) * t)
|
||||
.toString(16)
|
||||
.padStart(2, '0'),
|
||||
)
|
||||
.join('')
|
||||
'#' + rgb.map((v) => Math.round(v).toString(16).padStart(2, '0')).join('')
|
||||
);
|
||||
}
|
||||
|
||||
function gradientAt(stops: string[], t: number): string {
|
||||
if (stops.length === 0) return C.accent;
|
||||
if (stops.length === 1) return stops[0];
|
||||
const seg = Math.min(stops.length - 1, Math.floor(t * (stops.length - 1)));
|
||||
const lt = t * (stops.length - 1) - seg;
|
||||
return lerpHex(stops[seg], stops[seg + 1], lt);
|
||||
/**
|
||||
* One line of the wordmark, sampled the way ink samples it. `ink-gradient`
|
||||
* wraps ink's `Transform`, which runs `gradient.multiline()` per laid-out
|
||||
* line, so each line builds its own `tinygradient.rgb(steps)`: the segments
|
||||
* between stops get `round((steps - 1) / segments)` substeps, rebalanced
|
||||
* until they sum to `steps - 1`, and each segment then steps from its own
|
||||
* start. Sharing one ramp across lines, or lerping on a plain
|
||||
* `t = i / (len - 1)`, drifts a unit or two per channel and leaves a shorter
|
||||
* line one step short of the last stop.
|
||||
*/
|
||||
function lineRamp(stops: string[], steps: number): string[] {
|
||||
const rgb = stops.map(hexToRgb);
|
||||
const segments = rgb.length - 1;
|
||||
const substeps = Array.from({ length: segments }, () =>
|
||||
Math.max(1, Math.round((steps - 1) / segments)),
|
||||
);
|
||||
let total = substeps.reduce((sum, n) => sum + n, 1);
|
||||
while (total !== steps) {
|
||||
const grow = total < steps;
|
||||
const target = grow ? Math.min(...substeps) : Math.max(...substeps);
|
||||
substeps[substeps.indexOf(target)] += grow ? 1 : -1;
|
||||
total += grow ? 1 : -1;
|
||||
}
|
||||
const ramp: string[] = [];
|
||||
for (let s = 0; s < segments; s++) {
|
||||
const from = rgb[s];
|
||||
const to = rgb[s + 1];
|
||||
const n = substeps[s];
|
||||
ramp.push(rgbToHex(from));
|
||||
for (let i = 1; i < n; i++) {
|
||||
ramp.push(rgbToHex(from.map((v, k) => ((to[k] - v) / n) * i + v)));
|
||||
}
|
||||
}
|
||||
ramp.push(rgbToHex(rgb[segments]));
|
||||
return ramp;
|
||||
}
|
||||
|
||||
/** ASCII logo with the original horizontal gradient (themes GradientColors). */
|
||||
/** ASCII logo with the active theme's horizontal gradient (ink `ui.gradient`). */
|
||||
function GradientLogo({ logo }: { logo: string }) {
|
||||
const stops = GRADIENT;
|
||||
const lines = logo.replace(/^\n/, '').split('\n');
|
||||
const w = Math.max(...lines.map((l) => [...l].length), 1);
|
||||
return (
|
||||
<box flexDirection="column" flexShrink={0}>
|
||||
{lines.map((line, li) => (
|
||||
<box key={li} flexDirection="row">
|
||||
{[...line].map((ch, ci) => (
|
||||
<text key={ci} fg={gradientAt(LOGO_GRADIENT, ci / w)}>
|
||||
{ch}
|
||||
</text>
|
||||
))}
|
||||
</box>
|
||||
))}
|
||||
{lines.map((line, li) => {
|
||||
const chars = [...line];
|
||||
const ramp =
|
||||
stops.length >= 2
|
||||
? lineRamp(stops, Math.max(chars.length, stops.length))
|
||||
: [];
|
||||
return (
|
||||
<box key={li} flexDirection="row">
|
||||
{chars.map((ch, ci) => (
|
||||
<text key={ci} fg={ramp[ci]}>
|
||||
{ch}
|
||||
</text>
|
||||
))}
|
||||
</box>
|
||||
);
|
||||
})}
|
||||
</box>
|
||||
);
|
||||
}
|
||||
@@ -158,6 +188,7 @@ function buildBanner(config: Config, settings: LoadedSettings, width: number) {
|
||||
<box
|
||||
flexDirection="column"
|
||||
borderStyle="single"
|
||||
borderColor={C.borderDefault}
|
||||
paddingX={1}
|
||||
width={infoPanelWidth}
|
||||
flexGrow={showLogo ? 0 : 1}
|
||||
@@ -224,7 +255,10 @@ export function OpenTuiBanner({ config, settings }: OpenTuiBannerProps) {
|
||||
!config.getScreenReader() && !settings.merged.ui?.hideBanner;
|
||||
const banner = useMemo(
|
||||
() => (showBanner ? buildBanner(config, settings, width) : null),
|
||||
[showBanner, config, settings, width],
|
||||
// The palette is mutated in place and `settings` keeps its identity when
|
||||
// `/theme` writes it, so the revision is the only dep that sees a repaint.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[showBanner, config, settings, width, THEME_REVISION],
|
||||
);
|
||||
return banner;
|
||||
}
|
||||
@@ -110,6 +110,42 @@ describe('selectTuiRenderer', () => {
|
||||
expect(selection.reason).toContain('native FFI');
|
||||
});
|
||||
|
||||
it('keeps ink in screen-reader mode even on a supported runtime', () => {
|
||||
const selection = selectTuiRenderer(
|
||||
'opentui',
|
||||
supported,
|
||||
{},
|
||||
/* screenReader */ true,
|
||||
);
|
||||
expect(selection.renderer).toBe('ink');
|
||||
expect(selection.reason).toContain('screen-reader');
|
||||
// The gate must not become an unconditional ink fallback.
|
||||
expect(selectTuiRenderer('opentui', supported, {}, false).renderer).toBe(
|
||||
'opentui',
|
||||
);
|
||||
});
|
||||
|
||||
it('strict mode reports the screen-reader reason, not the runtime one', () => {
|
||||
expect(() =>
|
||||
selectTuiRenderer(
|
||||
'opentui',
|
||||
supported,
|
||||
{ [TUI_RENDERER_STRICT_ENV_VAR]: '1' },
|
||||
true,
|
||||
),
|
||||
).toThrow('screen-reader');
|
||||
// Screen-reader outranks the runtime probe: an unsupported runtime under
|
||||
// SR still names SR, so the failure points at the capability that matters.
|
||||
expect(() =>
|
||||
selectTuiRenderer(
|
||||
'opentui',
|
||||
unsupported,
|
||||
{ [TUI_RENDERER_STRICT_ENV_VAR]: '1' },
|
||||
true,
|
||||
),
|
||||
).toThrow('screen-reader');
|
||||
});
|
||||
|
||||
it('strict mode throws instead of falling back', () => {
|
||||
expect(() =>
|
||||
selectTuiRenderer('opentui', unsupported, {
|
||||
|
||||
@@ -132,11 +132,18 @@ export function isOpenTuiRuntimeSupported(
|
||||
* surfaces as a loud startup failure. The flag also survives into
|
||||
* {@link RendererSelection.strict} so the dispatcher can block the
|
||||
* boot-failure fallback the same way.
|
||||
*
|
||||
* Screen-reader mode is a second ink-only capability: it needs plain-text
|
||||
* append-only output on the main screen with no mouse, and the OpenTUI side
|
||||
* has no renderer path for it yet. Serving OpenTUI there would hand a
|
||||
* screen-reader user in-place differential redraws, which are worse than
|
||||
* useless, so the gate keeps ink.
|
||||
*/
|
||||
export function selectTuiRenderer(
|
||||
envValue: string | undefined = process.env[TUI_RENDERER_ENV_VAR],
|
||||
probe?: RuntimeVersionProbe,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
screenReader = false,
|
||||
): RendererSelection {
|
||||
const requested = envValue?.trim().toLowerCase();
|
||||
const strictValue = env[TUI_RENDERER_STRICT_ENV_VAR]?.trim().toLowerCase();
|
||||
@@ -150,6 +157,20 @@ export function selectTuiRenderer(
|
||||
strict,
|
||||
};
|
||||
}
|
||||
if (screenReader) {
|
||||
if (strict) {
|
||||
throw new Error(
|
||||
`${TUI_RENDERER_ENV_VAR}=${OPEN_TUI_RENDERER_VALUE} was requested, but screen-reader mode is only implemented by the ink renderer and ` +
|
||||
`${TUI_RENDERER_STRICT_ENV_VAR} forbids the silent ink fallback`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
renderer: 'ink',
|
||||
reason:
|
||||
'OpenTUI requested but screen-reader mode is only implemented by the ink renderer',
|
||||
strict,
|
||||
};
|
||||
}
|
||||
if (!isOpenTuiRuntimeSupported(probe)) {
|
||||
if (strict) {
|
||||
throw new Error(
|
||||
|
||||
@@ -0,0 +1,628 @@
|
||||
/** @jsxImportSource @opentui/react */
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Session picker parity (ink `SessionPicker`, shared by /resume and /delete):
|
||||
* the two-line row, the checkbox column and its commit set, the disabled live
|
||||
* session, the implicit-search entry, and the visible-item window. The port
|
||||
* used to render one hand-rolled line per session with no checkboxes at all,
|
||||
* so /delete could only ever remove the row under the cursor.
|
||||
*/
|
||||
|
||||
import { act, render, screen } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { SessionListItem } from '@qwen-code/qwen-code-core/services/sessionService.js';
|
||||
|
||||
interface RawKey {
|
||||
name?: string;
|
||||
sequence?: string;
|
||||
ctrl?: boolean;
|
||||
meta?: boolean;
|
||||
option?: boolean;
|
||||
super?: boolean;
|
||||
shift?: boolean;
|
||||
paste?: boolean;
|
||||
}
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const state = {
|
||||
keyboardHandlers: [] as Array<(key: RawKey) => void>,
|
||||
width: 100,
|
||||
height: 40,
|
||||
};
|
||||
async function buildJsxRuntime() {
|
||||
const React = await import('react');
|
||||
const jsx = (
|
||||
type: unknown,
|
||||
props: { children?: unknown } | null,
|
||||
key?: React.Key,
|
||||
) => {
|
||||
const config = key === undefined ? props : { ...props, key };
|
||||
const children = (config?.children ?? null) as React.ReactNode;
|
||||
if (type === 'box' || type === 'text') {
|
||||
return React.createElement(
|
||||
type === 'box' ? 'div' : 'span',
|
||||
key === undefined ? null : { key },
|
||||
children,
|
||||
);
|
||||
}
|
||||
return React.createElement(
|
||||
type as React.ElementType,
|
||||
config as Record<string, unknown>,
|
||||
children,
|
||||
);
|
||||
};
|
||||
return { jsx, jsxs: jsx, jsxDEV: jsx, Fragment: React.Fragment };
|
||||
}
|
||||
return { state, buildJsxRuntime };
|
||||
});
|
||||
|
||||
vi.mock('@opentui/react', async () => {
|
||||
const React = await import('react');
|
||||
return {
|
||||
useKeyboard: (handler: (key: RawKey) => void) => {
|
||||
const latest = React.useRef(handler);
|
||||
latest.current = handler;
|
||||
const stable = React.useRef<((key: RawKey) => void) | undefined>(
|
||||
undefined,
|
||||
);
|
||||
if (!stable.current) {
|
||||
stable.current = (key: RawKey) => latest.current(key);
|
||||
mocks.state.keyboardHandlers.push(stable.current);
|
||||
}
|
||||
},
|
||||
useTerminalDimensions: () => ({
|
||||
width: mocks.state.width,
|
||||
height: mocks.state.height,
|
||||
}),
|
||||
};
|
||||
});
|
||||
vi.mock('@opentui/core', () => ({
|
||||
SyntaxStyle: { fromStyles: () => ({}) },
|
||||
MouseButton: { LEFT: 0 },
|
||||
}));
|
||||
vi.mock('@opentui/react/jsx-runtime', () => mocks.buildJsxRuntime());
|
||||
vi.mock('@opentui/react/jsx-dev-runtime', () => mocks.buildJsxRuntime());
|
||||
vi.mock('./key-map.js', () => ({
|
||||
toOriginalKey: (key: RawKey) => ({
|
||||
name: key.name ?? '',
|
||||
ctrl: !!key.ctrl,
|
||||
meta: !!(key.meta || key.option || key.super),
|
||||
shift: !!key.shift,
|
||||
paste: !!key.paste,
|
||||
sequence: key.sequence ?? '',
|
||||
}),
|
||||
}));
|
||||
|
||||
import { OpenTuiSessionPicker } from './session-picker.js';
|
||||
|
||||
function press(key: RawKey) {
|
||||
if (mocks.state.keyboardHandlers.length === 0) {
|
||||
throw new Error('no keyboard handler registered');
|
||||
}
|
||||
act(() => {
|
||||
for (const handler of [...mocks.state.keyboardHandlers]) {
|
||||
handler({ ...key });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function typeChar(char: string) {
|
||||
press({ name: char, sequence: char });
|
||||
}
|
||||
|
||||
/**
|
||||
* Every key of one stdin read, against the handler one render registered: no
|
||||
* re-render lands between them, which is what a held arrow or a paste produces.
|
||||
*/
|
||||
function burst(keys: RawKey[]) {
|
||||
if (mocks.state.keyboardHandlers.length === 0) {
|
||||
throw new Error('no keyboard handler registered');
|
||||
}
|
||||
act(() => {
|
||||
for (const key of keys) {
|
||||
for (const handler of [...mocks.state.keyboardHandlers]) {
|
||||
handler({ ...key });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const NOW = Date.now();
|
||||
|
||||
function session(index: number, overrides: Partial<SessionListItem> = {}) {
|
||||
return {
|
||||
sessionId: `id-${String(index).padStart(2, '0')}`,
|
||||
mtime: NOW - 30_000,
|
||||
messageCount: 3,
|
||||
gitBranch: 'main',
|
||||
prompt: `Session ${String(index).padStart(2, '0')}`,
|
||||
...overrides,
|
||||
} as SessionListItem;
|
||||
}
|
||||
|
||||
function renderPicker(
|
||||
sessions: SessionListItem[],
|
||||
props: Partial<Parameters<typeof OpenTuiSessionPicker>[0]> = {},
|
||||
) {
|
||||
const onSelect = vi.fn();
|
||||
const onCancel = vi.fn();
|
||||
const onConfirmMulti = vi.fn();
|
||||
render(
|
||||
<OpenTuiSessionPicker
|
||||
// A null service renders ink's loading notice, so the list needs one.
|
||||
sessionService={{ listSessions: vi.fn() } as never}
|
||||
initialSessions={sessions}
|
||||
onSelect={onSelect}
|
||||
onCancel={onCancel}
|
||||
onConfirmMulti={onConfirmMulti}
|
||||
{...props}
|
||||
/>,
|
||||
);
|
||||
return { onSelect, onCancel, onConfirmMulti };
|
||||
}
|
||||
|
||||
/** The marker/checkbox line and the dim metadata line of one session row. */
|
||||
function lines(prompt: string): { row: string; meta: string } {
|
||||
const titleSpan = screen.getByText(prompt);
|
||||
const rowLine = titleSpan.parentElement?.parentElement as HTMLElement;
|
||||
const column = rowLine.parentElement as HTMLElement;
|
||||
return {
|
||||
row: rowLine.textContent ?? '',
|
||||
meta: (column.children[1] as HTMLElement).textContent ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
const ROW_TITLE = /^(Session \d\d|Unrelated work)$/;
|
||||
|
||||
/** The titles of the rendered rows, in order — i.e. what survived the filter. */
|
||||
function titles(): string[] {
|
||||
return screen.getAllByText(ROW_TITLE).map((el) => el.textContent ?? '');
|
||||
}
|
||||
|
||||
/** A loaded session, shaped the way `sessionService.loadSession` resolves. */
|
||||
function loadedSession(
|
||||
messages: Array<{ type: 'user' | 'assistant'; uuid: string; text: string }>,
|
||||
) {
|
||||
return {
|
||||
conversation: {
|
||||
messages: messages.map(({ type, uuid, text }) => ({
|
||||
uuid,
|
||||
type,
|
||||
message: {
|
||||
role: type === 'user' ? 'user' : 'model',
|
||||
parts: [{ text }],
|
||||
},
|
||||
})),
|
||||
},
|
||||
} as never;
|
||||
}
|
||||
|
||||
function serviceWith(loadSession: unknown) {
|
||||
return { listSessions: vi.fn(), loadSession } as never;
|
||||
}
|
||||
|
||||
async function flush() {
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
}
|
||||
|
||||
const ROW_META = 'just now · 3 messages · main';
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.state.keyboardHandlers.length = 0;
|
||||
mocks.state.width = 100;
|
||||
mocks.state.height = 40;
|
||||
});
|
||||
|
||||
describe('OpenTuiSessionPicker', () => {
|
||||
it('draws ink’s two-line row: marker plus title, then the metadata line', () => {
|
||||
renderPicker([session(1, { prompt: 'Fix the flaky test' }), session(2)]);
|
||||
expect(lines('Fix the flaky test').row).toBe('› Fix the flaky test');
|
||||
expect(lines('Fix the flaky test').meta).toBe(ROW_META);
|
||||
expect(lines('Session 02').row).toBe(' Session 02');
|
||||
});
|
||||
|
||||
it('omits the message segment when the count is unknown', () => {
|
||||
renderPicker([
|
||||
session(1, { messageCount: undefined, gitBranch: undefined }),
|
||||
]);
|
||||
expect(lines('Session 01').meta).toBe('just now');
|
||||
});
|
||||
|
||||
it('adds a checkbox column and commits the checked set, not the cursor row', () => {
|
||||
const { onSelect, onConfirmMulti } = renderPicker(
|
||||
[session(1), session(2), session(3)],
|
||||
{ enableMultiSelect: true },
|
||||
);
|
||||
expect(lines('Session 01').row).toBe('› [ ] Session 01');
|
||||
expect(screen.getByText(/^Space to select multiple/)).toBeTruthy();
|
||||
|
||||
press({ name: 'space', sequence: ' ' });
|
||||
press({ name: 'down' });
|
||||
press({ name: 'space', sequence: ' ' });
|
||||
// Space toggles in place; only ↓ moves the cursor.
|
||||
expect(lines('Session 01').row).toBe(' [x] Session 01');
|
||||
expect(lines('Session 02').row).toBe('› [x] Session 02');
|
||||
expect(screen.getByText(/^Space to toggle · 2 selected/)).toBeTruthy();
|
||||
|
||||
press({ name: 'return' });
|
||||
expect(onConfirmMulti).toHaveBeenCalledWith(['id-01', 'id-02']);
|
||||
expect(onSelect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps the disabled live session uncheckable and uncommittable', () => {
|
||||
const { onSelect, onConfirmMulti } = renderPicker(
|
||||
[session(1), session(2)],
|
||||
{ enableMultiSelect: true, disabledIds: ['id-01'] },
|
||||
);
|
||||
expect(lines('Session 01').meta).toBe(
|
||||
'just now · 3 messages · main · current — cannot delete',
|
||||
);
|
||||
|
||||
press({ name: 'space', sequence: ' ' });
|
||||
expect(lines('Session 01').row).toBe('› [ ] Session 01');
|
||||
press({ name: 'return' });
|
||||
expect(onSelect).not.toHaveBeenCalled();
|
||||
expect(onConfirmMulti).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('commits checks that the current filter hides', () => {
|
||||
const { onConfirmMulti } = renderPicker(
|
||||
[session(1), session(2, { prompt: 'Unrelated work' })],
|
||||
{ enableMultiSelect: true },
|
||||
);
|
||||
press({ name: 'space', sequence: ' ' });
|
||||
typeChar('U');
|
||||
expect(titles()).toEqual(['Unrelated work']);
|
||||
// The first Enter commits the query and returns to the list; only the
|
||||
// second one acts on the checked set.
|
||||
press({ name: 'return' });
|
||||
expect(onConfirmMulti).not.toHaveBeenCalled();
|
||||
press({ name: 'return' });
|
||||
// Search is a navigation aid: the commit set stays what was checked.
|
||||
expect(onConfirmMulti).toHaveBeenCalledWith(['id-01']);
|
||||
});
|
||||
|
||||
it('enters search on a typed letter and drops the query on Esc', () => {
|
||||
renderPicker([session(1), session(2, { prompt: 'Unrelated work' })]);
|
||||
expect(screen.getByText('Press / to search')).toBeTruthy();
|
||||
|
||||
typeChar('U');
|
||||
expect(
|
||||
(screen.getByText('Search:').parentElement as HTMLElement).textContent,
|
||||
).toBe('Search: U▌');
|
||||
expect(screen.getByText('(1 matches)')).toBeTruthy();
|
||||
expect(titles()).toEqual(['Unrelated work']);
|
||||
// ink suppresses the cursor highlight while the query is being edited.
|
||||
expect(lines('Unrelated work').row).toBe(' Unrelated work');
|
||||
|
||||
press({ name: 'escape' });
|
||||
expect(screen.getByText('Press / to search')).toBeTruthy();
|
||||
expect(titles()).toEqual(['Session 01', 'Unrelated work']);
|
||||
});
|
||||
|
||||
it('swallows a space in list mode instead of seeding a whitespace query', () => {
|
||||
renderPicker([session(1), session(2, { prompt: 'Unrelated work' })]);
|
||||
|
||||
// A space can reach the handler with no `name` at all, so the space branch
|
||||
// has to match on the sequence too: it is the only thing standing between a
|
||||
// stray space and a leading-whitespace query. ink drops it the same way.
|
||||
press({ sequence: ' ' });
|
||||
expect(screen.getByText('Press / to search')).toBeTruthy();
|
||||
expect(screen.queryByText('Search:')).toBeNull();
|
||||
expect(titles()).toEqual(['Session 01', 'Unrelated work']);
|
||||
});
|
||||
|
||||
it('cancels from list mode but only clears the query from search mode', () => {
|
||||
const { onCancel } = renderPicker([session(1)]);
|
||||
press({ name: 'escape' });
|
||||
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('windows the list and marks the scroll direction on the edge rows', () => {
|
||||
// 40 rows -> floor((40 - 7) / 3) = 11 visible items.
|
||||
const sessions = Array.from({ length: 14 }, (_, i) => session(i + 1));
|
||||
renderPicker(sessions);
|
||||
const visible = screen.getAllByText(/^Session \d\d$/);
|
||||
expect(visible).toHaveLength(11);
|
||||
expect(lines('Session 01').row).toBe('› Session 01');
|
||||
expect(lines('Session 11').row).toBe('↓ Session 11');
|
||||
});
|
||||
|
||||
it('loads the first page and pulls the next one when the sentinel shows', async () => {
|
||||
const listSessions = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
items: [session(1), session(2), session(3)],
|
||||
hasMore: true,
|
||||
nextCursor: 3,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
items: [session(4), session(5)],
|
||||
hasMore: false,
|
||||
nextCursor: undefined,
|
||||
});
|
||||
renderPicker([], {
|
||||
initialSessions: undefined,
|
||||
sessionService: { listSessions } as never,
|
||||
});
|
||||
expect(screen.getByText('Loading sessions...')).toBeTruthy();
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
expect(listSessions).toHaveBeenNthCalledWith(1, { size: 20 });
|
||||
expect(listSessions).toHaveBeenNthCalledWith(2, { size: 20, cursor: 3 });
|
||||
expect(titles()).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('selects the highlighted row on Enter when nothing is checked', () => {
|
||||
const { onSelect } = renderPicker([session(1), session(2)]);
|
||||
press({ name: 'down' });
|
||||
expect(lines('Session 02').row).toBe('› Session 02');
|
||||
press({ name: 'return' });
|
||||
expect(onSelect).toHaveBeenCalledWith('id-02');
|
||||
});
|
||||
|
||||
it('filters by branch on Ctrl+B and says so in the header', () => {
|
||||
renderPicker([session(1), session(2, { gitBranch: 'feature' })], {
|
||||
currentBranch: 'feature',
|
||||
});
|
||||
press({ name: 'b', sequence: 'b', ctrl: true });
|
||||
expect(screen.getByText('(branch: feature)')).toBeTruthy();
|
||||
expect(titles()).toEqual(['Session 02']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('OpenTuiSessionPicker Space-to-preview', () => {
|
||||
it('swaps the list for the loaded transcript and resumes it on Enter', async () => {
|
||||
const loadSession = vi.fn().mockResolvedValue(
|
||||
loadedSession([
|
||||
{ type: 'user', uuid: 'u1', text: 'Previewed prompt' },
|
||||
{ type: 'assistant', uuid: 'a1', text: 'Previewed answer' },
|
||||
]),
|
||||
);
|
||||
const { onSelect, onCancel } = renderPicker([session(1), session(2)], {
|
||||
enablePreview: true,
|
||||
sessionService: serviceWith(loadSession),
|
||||
});
|
||||
expect(screen.getByText(/^Space to preview/)).toBeTruthy();
|
||||
|
||||
press({ name: 'down' });
|
||||
press({ name: 'space', sequence: ' ' });
|
||||
expect(screen.getByText('Loading session preview...')).toBeTruthy();
|
||||
expect(screen.getByText('Enter to resume · Esc to back')).toBeTruthy();
|
||||
|
||||
await flush();
|
||||
expect(loadSession).toHaveBeenCalledWith('id-02');
|
||||
// ink's preview is its own tree: the title comes off the session entry, the
|
||||
// meta line counts messages first (unlike the row, which leads with time),
|
||||
// and the list rows are gone.
|
||||
expect(screen.getByText('Session 02')).toBeTruthy();
|
||||
expect(screen.getByText('3 messages · just now · main')).toBeTruthy();
|
||||
expect(screen.queryAllByText(ROW_META)).toHaveLength(0);
|
||||
expect(screen.getByText('Previewed prompt')).toBeTruthy();
|
||||
|
||||
press({ name: 'return' });
|
||||
expect(onSelect).toHaveBeenCalledWith('id-02');
|
||||
expect(onCancel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('comes back to the list on Esc with the cursor and the query untouched', async () => {
|
||||
const { onCancel } = renderPicker(
|
||||
[
|
||||
session(1, { prompt: 'Alpha work' }),
|
||||
session(2, { prompt: 'Beta work' }),
|
||||
session(3, { prompt: 'Gamma work' }),
|
||||
],
|
||||
{
|
||||
enablePreview: true,
|
||||
sessionService: serviceWith(
|
||||
vi.fn().mockResolvedValue(loadedSession([])),
|
||||
),
|
||||
},
|
||||
);
|
||||
typeChar('a');
|
||||
press({ name: 'return' });
|
||||
press({ name: 'down' });
|
||||
expect(lines('Beta work').row).toBe('› Beta work');
|
||||
|
||||
press({ name: 'space', sequence: ' ' });
|
||||
await flush();
|
||||
press({ name: 'escape' });
|
||||
|
||||
expect(onCancel).not.toHaveBeenCalled();
|
||||
expect(
|
||||
(screen.getByText('Filter:').parentElement as HTMLElement).textContent,
|
||||
).toBe('Filter: a');
|
||||
expect(lines('Beta work').row).toBe('› Beta work');
|
||||
expect(screen.getByText(/^Space to preview/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('gives the preview the whole keyboard, and ctrl+c backs out instead of cancelling', async () => {
|
||||
const { onCancel } = renderPicker([session(1), session(2)], {
|
||||
enablePreview: true,
|
||||
sessionService: serviceWith(vi.fn().mockResolvedValue(loadedSession([]))),
|
||||
});
|
||||
press({ name: 'space', sequence: ' ' });
|
||||
await flush();
|
||||
|
||||
press({ name: 'down' });
|
||||
press({ name: 'j' });
|
||||
typeChar('z');
|
||||
expect(screen.getByText('Enter to resume · Esc to back')).toBeTruthy();
|
||||
|
||||
press({ name: 'c', sequence: 'c', ctrl: true });
|
||||
expect(onCancel).not.toHaveBeenCalled();
|
||||
// Neither the cursor nor the query moved while the preview was up.
|
||||
expect(lines('Session 01').row).toBe('› Session 01');
|
||||
expect(screen.getByText('Press / to search')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders ink’s not-found wording instead of a blank body', async () => {
|
||||
renderPicker([session(1)], {
|
||||
enablePreview: true,
|
||||
sessionService: serviceWith(vi.fn().mockResolvedValue(undefined)),
|
||||
});
|
||||
press({ name: 'space', sequence: ' ' });
|
||||
await flush();
|
||||
expect(screen.getByText('Session not found')).toBeTruthy();
|
||||
expect(screen.getByText('Enter to resume · Esc to back')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders a thrown load failure', async () => {
|
||||
renderPicker([session(1)], {
|
||||
enablePreview: true,
|
||||
sessionService: serviceWith(
|
||||
vi.fn().mockRejectedValue(new Error('JSONL is corrupt')),
|
||||
),
|
||||
});
|
||||
press({ name: 'space', sequence: ' ' });
|
||||
await flush();
|
||||
expect(screen.getByText('JSONL is corrupt')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('counts unique user/assistant uuids when the list entry has no count', async () => {
|
||||
renderPicker([session(1, { messageCount: undefined })], {
|
||||
enablePreview: true,
|
||||
sessionService: serviceWith(
|
||||
vi.fn().mockResolvedValue(
|
||||
loadedSession([
|
||||
{ type: 'user', uuid: 'u1', text: 'First' },
|
||||
{ type: 'assistant', uuid: 'a1', text: 'Answer' },
|
||||
{ type: 'user', uuid: 'u1', text: 'First' },
|
||||
{ type: 'assistant', uuid: 'a2', text: 'Second answer' },
|
||||
]),
|
||||
),
|
||||
),
|
||||
});
|
||||
press({ name: 'space', sequence: ' ' });
|
||||
await flush();
|
||||
expect(screen.getByText('3 messages · just now · main')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('leaves Space to the checkboxes and the hint off in multi-select', () => {
|
||||
renderPicker([session(1), session(2)], {
|
||||
enableMultiSelect: true,
|
||||
enablePreview: true,
|
||||
});
|
||||
expect(screen.queryByText(/Space to preview/)).toBeNull();
|
||||
|
||||
press({ name: 'space', sequence: ' ' });
|
||||
expect(lines('Session 01').row).toBe('› [x] Session 01');
|
||||
expect(screen.queryByText('Enter to resume · Esc to back')).toBeNull();
|
||||
});
|
||||
|
||||
it('advertises nothing to a flow that did not opt in', () => {
|
||||
renderPicker([session(1)]);
|
||||
expect(screen.queryByText(/Space to preview/)).toBeNull();
|
||||
|
||||
press({ name: 'space', sequence: ' ' });
|
||||
expect(screen.queryByText('Loading session preview...')).toBeNull();
|
||||
expect(screen.queryByText('Enter to resume · Esc to back')).toBeNull();
|
||||
expect(screen.queryAllByText(ROW_META)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('ignores a preview load that resolves after the user moved on', async () => {
|
||||
const resolvers: Array<(value: unknown) => void> = [];
|
||||
const loadSession = vi.fn().mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolvers.push(resolve);
|
||||
}),
|
||||
);
|
||||
renderPicker(
|
||||
[
|
||||
session(1, { prompt: 'Alpha work' }),
|
||||
session(2, { prompt: 'Beta work' }),
|
||||
],
|
||||
{ enablePreview: true, sessionService: serviceWith(loadSession) },
|
||||
);
|
||||
|
||||
press({ name: 'space', sequence: ' ' });
|
||||
// Leave before it resolves, then preview the other session.
|
||||
press({ name: 'escape' });
|
||||
press({ name: 'down' });
|
||||
press({ name: 'space', sequence: ' ' });
|
||||
await act(async () => {
|
||||
resolvers[1](
|
||||
loadedSession([{ type: 'user', uuid: 'b1', text: 'Beta transcript' }]),
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
expect(screen.getByText('Beta transcript')).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
resolvers[0](
|
||||
loadedSession([{ type: 'user', uuid: 'a1', text: 'Alpha transcript' }]),
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
expect(screen.queryByText('Alpha transcript')).toBeNull();
|
||||
expect(screen.getByText('Beta transcript')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('OpenTuiSessionPicker under one stdin read', () => {
|
||||
const DOWN = { name: 'down', sequence: '\x1b[B' };
|
||||
const UP = { name: 'up', sequence: '\x1b[A' };
|
||||
const SPACE = { name: 'space', sequence: ' ' };
|
||||
const RETURN = { name: 'return', sequence: '\r' };
|
||||
|
||||
it('resumes the row the arrows of the same read reached', () => {
|
||||
const { onSelect } = renderPicker([session(1), session(2), session(3)]);
|
||||
|
||||
// Read from the render that armed the handler, Enter would resume the row
|
||||
// that was highlighted before the arrows moved.
|
||||
burst([DOWN, DOWN, RETURN]);
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith('id-03');
|
||||
});
|
||||
|
||||
it('commits the checks the same read made', () => {
|
||||
const { onSelect, onConfirmMulti } = renderPicker(
|
||||
[session(1), session(2), session(3)],
|
||||
{ enableMultiSelect: true },
|
||||
);
|
||||
|
||||
// Read from the render that armed the handler, the checked set is still
|
||||
// empty at Enter, so the burst would fall through to the single-row path.
|
||||
burst([SPACE, DOWN, SPACE, RETURN]);
|
||||
|
||||
expect(onConfirmMulti).toHaveBeenCalledWith(['id-01', 'id-02']);
|
||||
expect(onSelect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps editing the query an arrow of the same read focused', () => {
|
||||
const { onSelect } = renderPicker([session(1), session(2)]);
|
||||
|
||||
// ↑ off the first row focuses the query. Read from the render that armed
|
||||
// the handler, Enter still sees the list and resumes a row instead.
|
||||
burst([UP, RETURN]);
|
||||
|
||||
expect(onSelect).not.toHaveBeenCalled();
|
||||
// Enter handed the keyboard back to the list, which is where the burst
|
||||
// ends either way — the row it did not resume is the point.
|
||||
expect(screen.getByText('Press / to search')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('gives the preview a key of the same read opened', () => {
|
||||
const { onSelect } = renderPicker([session(1), session(2)], {
|
||||
enablePreview: true,
|
||||
sessionService: serviceWith(vi.fn().mockResolvedValue(loadedSession([]))),
|
||||
});
|
||||
|
||||
// Space puts the preview up and the preview owns the keyboard, so the ↓ is
|
||||
// dropped. Read from the render that armed the handler, the preview is not
|
||||
// up yet, the ↓ moves the cursor and Enter resumes the other session.
|
||||
burst([SPACE, DOWN, RETURN]);
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith('id-01');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,817 @@
|
||||
/* eslint-disable react/no-unknown-property */
|
||||
/** @jsxImportSource @opentui/react */
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* OpenTUI port of the ink `SessionPicker`, shared by `/resume` and `/delete`
|
||||
* exactly as ink shares one component between them.
|
||||
*
|
||||
* The renderer-independent halves are ink's own modules — `filterSessions` and
|
||||
* `SESSION_PAGE_SIZE` for the list, `formatRelativeTime` / `formatMessageCount`
|
||||
* / `truncateText` for the row text, `useSessionSearchInput` for the query
|
||||
* buffer — so filtering and search editing cannot drift between backends. Only
|
||||
* the layout and the key dispatch are ported here, against `useKeyboard`.
|
||||
*
|
||||
* Space-to-preview is ink's `SessionPreview`: a separate borderless tree that
|
||||
* replaces the list, Enter resumes it and Esc comes back with the cursor, the
|
||||
* checks and the query untouched.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useKeyboard, useTerminalDimensions } from '@opentui/react';
|
||||
import type {
|
||||
ListSessionsResult,
|
||||
ResumedSessionData,
|
||||
SessionListItem,
|
||||
SessionService,
|
||||
} from '@qwen-code/qwen-code-core/services/sessionService.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import {
|
||||
filterSessions,
|
||||
formatMessageCount,
|
||||
SESSION_PAGE_SIZE,
|
||||
truncateText,
|
||||
type SessionState,
|
||||
} from '../utils/sessionPickerUtils.js';
|
||||
import { formatRelativeTime } from '../utils/formatters.js';
|
||||
import {
|
||||
isPrintableSearchChar,
|
||||
useSessionSearchInput,
|
||||
} from '../hooks/useSessionSearchInput.js';
|
||||
import { toOriginalKey } from './key-map.js';
|
||||
import { isPrintableKeyInput } from './input-prompt-key.js';
|
||||
import { useBatchSafeCursor, useBatchSafeState } from './batch-cursor.js';
|
||||
import { OpenTuiTranscriptView } from './transcript-view.js';
|
||||
import { resumeEventsFromSession } from './resume-session.js';
|
||||
import { foldLiveEvent, type LiveHistoryItem } from './live-session-model.js';
|
||||
import { C } from './theme.js';
|
||||
|
||||
export interface OpenTuiSessionPickerProps {
|
||||
sessionService: SessionService | null;
|
||||
currentBranch?: string;
|
||||
onSelect: (sessionId: string) => void;
|
||||
onCancel: () => void;
|
||||
/** Header title; ink defaults to "Resume Session". */
|
||||
title?: string;
|
||||
/** Pre-filtered sessions (`/resume <title>` with several matches). */
|
||||
initialSessions?: SessionListItem[];
|
||||
/**
|
||||
* Enable Space-to-preview. Off by default, exactly like ink: the preview's
|
||||
* Enter forwards to `onSelect`, which resumes for `/resume` but would
|
||||
* commit a destructive flow. Multi-select owns Space, so it also disables
|
||||
* the preview and its footer hint.
|
||||
*/
|
||||
enablePreview?: boolean;
|
||||
enableMultiSelect?: boolean;
|
||||
onConfirmMulti?: (sessionIds: string[]) => void;
|
||||
/** Rows the user may not check or Enter — the live session, for /delete. */
|
||||
disabledIds?: readonly string[];
|
||||
}
|
||||
|
||||
/** Header/search/list/footers/separators/borders, in ink's own accounting. */
|
||||
const RESERVED_LINES = 7;
|
||||
/** Prompt row + metadata row + the margin between items. */
|
||||
const ITEM_HEIGHT = 3;
|
||||
|
||||
/** The three bodies ink's `SessionPreview` renders before/after its load. */
|
||||
type PreviewLoad =
|
||||
| { status: 'loading' }
|
||||
| { status: 'error'; message: string }
|
||||
| {
|
||||
status: 'ready';
|
||||
items: readonly LiveHistoryItem[];
|
||||
messageCount: number;
|
||||
};
|
||||
|
||||
/** Folded into a list of its own: the live transcript is never touched. No
|
||||
* config is passed, so tool groups degrade to name-only exactly as ink's
|
||||
* preview does (it passes `null`); Enter resumes for full fidelity. */
|
||||
function foldPreviewItems(
|
||||
data: ResumedSessionData,
|
||||
): readonly LiveHistoryItem[] {
|
||||
let items: readonly LiveHistoryItem[] = [];
|
||||
for (const event of resumeEventsFromSession(data)) {
|
||||
items = foldLiveEvent(items, event);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
/** `listSessions` omits the count, so ink counts the unique user/assistant
|
||||
* uuids of the conversation it just loaded. */
|
||||
function countPreviewMessages(data: ResumedSessionData): number {
|
||||
const seen = new Set<string>();
|
||||
for (const message of data.conversation.messages) {
|
||||
if (message.type === 'user' || message.type === 'assistant') {
|
||||
seen.add(message.uuid);
|
||||
}
|
||||
}
|
||||
return seen.size;
|
||||
}
|
||||
|
||||
export function OpenTuiSessionPicker(props: OpenTuiSessionPickerProps) {
|
||||
const {
|
||||
sessionService,
|
||||
currentBranch,
|
||||
onSelect,
|
||||
onCancel,
|
||||
title,
|
||||
initialSessions,
|
||||
enablePreview = false,
|
||||
enableMultiSelect = false,
|
||||
onConfirmMulti,
|
||||
disabledIds,
|
||||
} = props;
|
||||
|
||||
const { width, height } = useTerminalDimensions();
|
||||
const boxWidth = Math.max(0, width - 4);
|
||||
const maxVisibleItems = Math.max(
|
||||
1,
|
||||
Math.floor((height - RESERVED_LINES) / ITEM_HEIGHT),
|
||||
);
|
||||
|
||||
const hasInitialSessions = initialSessions !== undefined;
|
||||
const {
|
||||
cursor: selectedIndex,
|
||||
cursorRef: selectedIndexRef,
|
||||
setCursor: setSelectedIndex,
|
||||
} = useBatchSafeCursor(0);
|
||||
const [sessionState, setSessionState] = useState<SessionState>(() =>
|
||||
hasInitialSessions
|
||||
? { sessions: initialSessions, hasMore: false, nextCursor: undefined }
|
||||
: { sessions: [], hasMore: true, nextCursor: undefined },
|
||||
);
|
||||
const [filterByBranch, setFilterByBranch] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(!hasInitialSessions);
|
||||
const {
|
||||
value: viewModeValue,
|
||||
ref: viewModeRef,
|
||||
setValue: setViewMode,
|
||||
} = useBatchSafeState<'list' | 'search'>('list');
|
||||
const {
|
||||
value: checkedIds,
|
||||
ref: checkedIdsRef,
|
||||
setValue: setCheckedIds,
|
||||
} = useBatchSafeState<ReadonlySet<string>>(new Set());
|
||||
// Space binds either the checkbox or the preview, never both, so the hint
|
||||
// can't advertise a binding multi-select already took.
|
||||
const previewEnabled = enablePreview && !enableMultiSelect;
|
||||
const {
|
||||
value: previewSessionId,
|
||||
ref: previewSessionIdRef,
|
||||
setValue: setPreviewSessionId,
|
||||
} = useBatchSafeState<string | null>(null);
|
||||
const [previewLoad, setPreviewLoad] = useState<PreviewLoad>({
|
||||
status: 'loading',
|
||||
});
|
||||
|
||||
const disabledIdSet = useMemo(
|
||||
() => new Set(disabledIds ?? []),
|
||||
[disabledIds],
|
||||
);
|
||||
|
||||
const toggleChecked = useCallback(
|
||||
(sessionId: string) => {
|
||||
if (disabledIdSet.has(sessionId)) return;
|
||||
const next = new Set(checkedIdsRef.current);
|
||||
if (next.has(sessionId)) {
|
||||
next.delete(sessionId);
|
||||
} else {
|
||||
next.add(sessionId);
|
||||
}
|
||||
setCheckedIds(next);
|
||||
},
|
||||
[checkedIdsRef, setCheckedIds, disabledIdSet],
|
||||
);
|
||||
|
||||
const exitToList = useCallback(() => setViewMode('list'), [setViewMode]);
|
||||
const { searchQuery, setSearchQuery, handleSearchKey } =
|
||||
useSessionSearchInput({ onExitToList: exitToList });
|
||||
|
||||
const isLoadingMoreRef = useRef(false);
|
||||
|
||||
const filteredSessions = useMemo(
|
||||
() =>
|
||||
filterSessions(
|
||||
sessionState.sessions,
|
||||
filterByBranch,
|
||||
currentBranch,
|
||||
searchQuery,
|
||||
),
|
||||
[sessionState.sessions, filterByBranch, currentBranch, searchQuery],
|
||||
);
|
||||
|
||||
const scrollOffset = useMemo(() => {
|
||||
if (filteredSessions.length <= maxVisibleItems) return 0;
|
||||
const halfVisible = Math.floor(maxVisibleItems / 2);
|
||||
return Math.min(
|
||||
filteredSessions.length - maxVisibleItems,
|
||||
Math.max(0, selectedIndex - halfVisible),
|
||||
);
|
||||
}, [filteredSessions.length, maxVisibleItems, selectedIndex]);
|
||||
|
||||
const visibleSessions = useMemo(
|
||||
() => filteredSessions.slice(scrollOffset, scrollOffset + maxVisibleItems),
|
||||
[filteredSessions, maxVisibleItems, scrollOffset],
|
||||
);
|
||||
const showScrollUp = scrollOffset > 0;
|
||||
const showScrollDown =
|
||||
scrollOffset + maxVisibleItems < filteredSessions.length;
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionService || hasInitialSessions) return;
|
||||
let alive = true;
|
||||
void (async () => {
|
||||
try {
|
||||
const result: ListSessionsResult = await sessionService.listSessions({
|
||||
size: SESSION_PAGE_SIZE,
|
||||
});
|
||||
if (!alive) return;
|
||||
setSessionState({
|
||||
sessions: result.items,
|
||||
hasMore: result.hasMore,
|
||||
nextCursor: result.nextCursor,
|
||||
});
|
||||
} finally {
|
||||
if (alive) setIsLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [sessionService, hasInitialSessions]);
|
||||
|
||||
const loadMoreSessions = useCallback(async () => {
|
||||
if (!sessionService || !sessionState.hasMore || isLoadingMoreRef.current) {
|
||||
return;
|
||||
}
|
||||
isLoadingMoreRef.current = true;
|
||||
try {
|
||||
const result: ListSessionsResult = await sessionService.listSessions({
|
||||
size: SESSION_PAGE_SIZE,
|
||||
cursor: sessionState.nextCursor,
|
||||
});
|
||||
setSessionState((prev) => ({
|
||||
sessions: [...prev.sessions, ...result.items],
|
||||
hasMore: result.hasMore && result.nextCursor !== undefined,
|
||||
nextCursor: result.nextCursor,
|
||||
}));
|
||||
} finally {
|
||||
isLoadingMoreRef.current = false;
|
||||
}
|
||||
}, [sessionService, sessionState.hasMore, sessionState.nextCursor]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedIndex(0);
|
||||
}, [filterByBranch, searchQuery, setSelectedIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
selectedIndex >= filteredSessions.length &&
|
||||
filteredSessions.length > 0
|
||||
) {
|
||||
setSelectedIndex(filteredSessions.length - 1);
|
||||
}
|
||||
}, [filteredSessions.length, selectedIndex, setSelectedIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading || !sessionState.hasMore || isLoadingMoreRef.current) return;
|
||||
const sentinelVisible =
|
||||
scrollOffset + maxVisibleItems >= filteredSessions.length;
|
||||
if (filteredSessions.length === 0 || sentinelVisible) {
|
||||
void loadMoreSessions();
|
||||
}
|
||||
}, [
|
||||
filteredSessions.length,
|
||||
isLoading,
|
||||
loadMoreSessions,
|
||||
maxVisibleItems,
|
||||
scrollOffset,
|
||||
sessionState.hasMore,
|
||||
]);
|
||||
|
||||
// ink SessionPreview: load on entry and drop whatever resolves after the
|
||||
// user left, so Esc can't be followed by a stale transcript painting over
|
||||
// the list.
|
||||
useEffect(() => {
|
||||
if (previewSessionId === null || !sessionService) return;
|
||||
let alive = true;
|
||||
setPreviewLoad({ status: 'loading' });
|
||||
void (async () => {
|
||||
try {
|
||||
const data = await sessionService.loadSession(previewSessionId);
|
||||
if (!alive) return;
|
||||
if (!data) {
|
||||
setPreviewLoad({ status: 'error', message: 'Session not found' });
|
||||
return;
|
||||
}
|
||||
setPreviewLoad({
|
||||
status: 'ready',
|
||||
items: foldPreviewItems(data),
|
||||
messageCount: countPreviewMessages(data),
|
||||
});
|
||||
} catch (error) {
|
||||
if (!alive) return;
|
||||
setPreviewLoad({
|
||||
status: 'error',
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [previewSessionId, sessionService]);
|
||||
|
||||
const moveSelection = useCallback(
|
||||
(delta: -1 | 1) => {
|
||||
const length = filteredSessions.length;
|
||||
if (length === 0) return;
|
||||
setSelectedIndex(
|
||||
Math.min(length - 1, Math.max(0, selectedIndexRef.current + delta)),
|
||||
);
|
||||
},
|
||||
[filteredSessions.length, selectedIndexRef, setSelectedIndex],
|
||||
);
|
||||
|
||||
useKeyboard((raw) => {
|
||||
const key = toOriginalKey(raw);
|
||||
const { name, sequence, ctrl } = key;
|
||||
|
||||
// ink hands the whole keyboard to SessionPreview while it is up (the
|
||||
// picker's own handler is deactivated), so only back and resume exist
|
||||
// here and ctrl+c backs out instead of cancelling the dialog.
|
||||
const previewId = previewSessionIdRef.current;
|
||||
if (previewId !== null) {
|
||||
if (name === 'escape' || (ctrl && name === 'c')) {
|
||||
setPreviewSessionId(null);
|
||||
} else if (name === 'return' || name === 'enter') {
|
||||
onSelect(previewId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (ctrl && name === 'c') {
|
||||
onCancel();
|
||||
return;
|
||||
}
|
||||
|
||||
if (name === 'return' || name === 'enter') {
|
||||
if (viewModeRef.current === 'search') {
|
||||
// Nothing to commit to — keep editing.
|
||||
if (filteredSessions.length === 0) return;
|
||||
setViewMode('list');
|
||||
return;
|
||||
}
|
||||
if (
|
||||
enableMultiSelect &&
|
||||
checkedIdsRef.current.size > 0 &&
|
||||
onConfirmMulti
|
||||
) {
|
||||
// Commit every checked id, not just the filtered-in ones: a search
|
||||
// is a navigation aid, and intersecting with it would silently drop
|
||||
// checks the user made before typing. Ordered by the full list so the
|
||||
// receiver reports deletions in display order.
|
||||
const orderedIds = sessionState.sessions
|
||||
.map((session) => session.sessionId)
|
||||
.filter(
|
||||
(id) => checkedIdsRef.current.has(id) && !disabledIdSet.has(id),
|
||||
);
|
||||
if (orderedIds.length > 0) onConfirmMulti(orderedIds);
|
||||
return;
|
||||
}
|
||||
const session = filteredSessions[selectedIndexRef.current];
|
||||
if (session && !disabledIdSet.has(session.sessionId)) {
|
||||
onSelect(session.sessionId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const isNavUp = name === 'up' || (ctrl && name === 'p');
|
||||
const isNavDown = name === 'down' || (ctrl && name === 'n');
|
||||
if (isNavUp || isNavDown) {
|
||||
if (viewModeRef.current === 'search') {
|
||||
if (filteredSessions.length === 0) return;
|
||||
setViewMode('list');
|
||||
return;
|
||||
}
|
||||
if (
|
||||
isNavUp &&
|
||||
filteredSessions.length > 0 &&
|
||||
selectedIndexRef.current === 0
|
||||
) {
|
||||
setViewMode('search');
|
||||
return;
|
||||
}
|
||||
moveSelection(isNavUp ? -1 : +1);
|
||||
return;
|
||||
}
|
||||
|
||||
// While the query is focused it owns the keyboard: anything
|
||||
// `handleSearchKey` doesn't claim is swallowed, and the mode-independent
|
||||
// shortcuts above are the only way out.
|
||||
if (viewModeRef.current === 'search') {
|
||||
handleSearchKey(key);
|
||||
return;
|
||||
}
|
||||
|
||||
if (name === 'escape') {
|
||||
if (searchQuery !== '') {
|
||||
setSearchQuery('');
|
||||
} else {
|
||||
onCancel();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (name === 'k') {
|
||||
moveSelection(-1);
|
||||
return;
|
||||
}
|
||||
if (name === 'j') {
|
||||
moveSelection(+1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (name === 'space' || sequence === ' ') {
|
||||
const session = filteredSessions[selectedIndexRef.current];
|
||||
if (!session) return;
|
||||
if (enableMultiSelect) toggleChecked(session.sessionId);
|
||||
else if (previewEnabled) setPreviewSessionId(session.sessionId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (ctrl && (name === 'b' || name === 'B')) {
|
||||
if (currentBranch) setFilterByBranch((prev) => !prev);
|
||||
return;
|
||||
}
|
||||
|
||||
if (sequence === '/') {
|
||||
setViewMode('search');
|
||||
return;
|
||||
}
|
||||
|
||||
// Typing a letter in list mode enters search with that letter already in
|
||||
// the buffer; release events are dropped so a key isn't inserted twice.
|
||||
if (isPrintableSearchChar(key) && isPrintableKeyInput(raw)) {
|
||||
setViewMode('search');
|
||||
setSearchQuery((query) => query + sequence);
|
||||
}
|
||||
});
|
||||
|
||||
const maxPromptWidth = Math.max(0, boxWidth - 6);
|
||||
const checkboxWidth = enableMultiSelect ? 4 : 0;
|
||||
const isSearchActive = viewModeValue === 'search';
|
||||
|
||||
const committableCheckedCount = useMemo(() => {
|
||||
let count = 0;
|
||||
for (const id of checkedIds) {
|
||||
if (!disabledIdSet.has(id)) count++;
|
||||
}
|
||||
return count;
|
||||
}, [checkedIds, disabledIdSet]);
|
||||
|
||||
const headerSuffix = [
|
||||
filterByBranch && currentBranch
|
||||
? t('(branch: {{branch}})', { branch: currentBranch })
|
||||
: '',
|
||||
searchQuery !== ''
|
||||
? t('({{count}} matches)', {
|
||||
count: String(filteredSessions.length),
|
||||
})
|
||||
: '',
|
||||
]
|
||||
.filter((part) => part !== '')
|
||||
.join(' ');
|
||||
|
||||
const previewed = filteredSessions.find(
|
||||
(session) => session.sessionId === previewSessionId,
|
||||
);
|
||||
const previewTitle =
|
||||
previewed?.customTitle ||
|
||||
previewed?.prompt ||
|
||||
previewed?.goalObjective ||
|
||||
t('Session Preview');
|
||||
// ink's preview meta line: the list entry's count when it has one, else the
|
||||
// count taken off the loaded conversation, then time and branch.
|
||||
const previewCount =
|
||||
previewed?.messageCount ??
|
||||
(previewLoad.status === 'ready' ? previewLoad.messageCount : undefined);
|
||||
const previewMeta = [
|
||||
previewCount === undefined ? '' : formatMessageCount(previewCount),
|
||||
previewed ? formatRelativeTime(previewed.mtime) : '',
|
||||
previewed?.gitBranch ?? '',
|
||||
]
|
||||
.filter((part) => part !== '')
|
||||
.join(' · ');
|
||||
|
||||
const footerTail = `${currentBranch ? t(' to toggle branch · ') : ''}${
|
||||
previewEnabled ? t('Space to preview · ') : ''
|
||||
}${
|
||||
enableMultiSelect
|
||||
? committableCheckedCount > 0
|
||||
? t('Space to toggle · {{count}} selected · ', {
|
||||
count: String(committableCheckedCount),
|
||||
})
|
||||
: t('Space to select multiple · ')
|
||||
: ''
|
||||
}${t('↑↓ to navigate · Type to search · Esc to cancel')}`;
|
||||
|
||||
// ink returns a separate `SessionPreview` tree here rather than swapping the
|
||||
// body of the list: the preview has no border and its transcript spans the
|
||||
// full inner width. All picker state lives above, so the list comes back with
|
||||
// the cursor, the checks and the query untouched.
|
||||
if (previewSessionId !== null) {
|
||||
return (
|
||||
<box
|
||||
key="preview"
|
||||
flexDirection="column"
|
||||
width={boxWidth}
|
||||
height={Math.max(0, height - 1)}
|
||||
overflow="hidden"
|
||||
marginTop={1}
|
||||
flexShrink={0}
|
||||
>
|
||||
<box paddingLeft={1} paddingRight={1}>
|
||||
<text fg={C.text} attributes={1}>
|
||||
{previewTitle}
|
||||
</text>
|
||||
</box>
|
||||
|
||||
<Rule width={boxWidth} />
|
||||
|
||||
<box flexDirection="column" flexGrow={1} overflow="hidden">
|
||||
{previewLoad.status === 'ready' ? (
|
||||
<OpenTuiTranscriptView
|
||||
items={previewLoad.items}
|
||||
availableWidth={boxWidth}
|
||||
availableTerminalHeight={height}
|
||||
thoughtsExpanded
|
||||
/>
|
||||
) : previewLoad.status === 'error' ? (
|
||||
<CenteredNotice text={previewLoad.message} color={C.red} />
|
||||
) : (
|
||||
<CenteredNotice text={t('Loading session preview...')} />
|
||||
)}
|
||||
</box>
|
||||
|
||||
<Rule width={boxWidth} />
|
||||
|
||||
{previewMeta !== '' ? (
|
||||
<box paddingLeft={1} paddingRight={1}>
|
||||
<text fg={C.dim}>{previewMeta}</text>
|
||||
</box>
|
||||
) : null}
|
||||
|
||||
<box paddingLeft={1} paddingRight={1}>
|
||||
<text fg={C.dim}>{t('Enter to resume · Esc to back')}</text>
|
||||
</box>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
const headerTitle = title ?? t('Resume Session');
|
||||
|
||||
return (
|
||||
<box
|
||||
key="list"
|
||||
flexDirection="column"
|
||||
borderStyle="rounded"
|
||||
borderColor={C.borderDefault}
|
||||
width={boxWidth}
|
||||
height={Math.max(0, height - 1)}
|
||||
overflow="hidden"
|
||||
marginTop={1}
|
||||
flexShrink={0}
|
||||
>
|
||||
<box flexDirection="row" paddingLeft={1} paddingRight={1}>
|
||||
<text fg={C.text} attributes={1}>
|
||||
{headerTitle}
|
||||
{headerSuffix ? ' ' : ''}
|
||||
</text>
|
||||
{headerSuffix ? <text fg={C.dim}>{headerSuffix}</text> : null}
|
||||
</box>
|
||||
|
||||
{/* Two states share this row at constant height so the visible-item
|
||||
count doesn't shift between them. */}
|
||||
<box flexDirection="row" paddingLeft={1} paddingRight={1}>
|
||||
{isSearchActive ? (
|
||||
<text fg={C.dim}>
|
||||
{t('Search: ')}
|
||||
<span fg={C.text}>{searchQuery}</span>
|
||||
<span fg={C.dim}>{'▌'}</span>
|
||||
</text>
|
||||
) : searchQuery !== '' ? (
|
||||
<text fg={C.dim}>
|
||||
{t('Filter: ')}
|
||||
<span fg={C.text}>{searchQuery}</span>
|
||||
</text>
|
||||
) : (
|
||||
<text fg={C.dim}>{t('Press / to search')}</text>
|
||||
)}
|
||||
</box>
|
||||
|
||||
<Rule width={boxWidth} />
|
||||
|
||||
<box
|
||||
flexDirection="column"
|
||||
flexGrow={1}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
overflow="hidden"
|
||||
>
|
||||
{!sessionService || isLoading ? (
|
||||
<CenteredNotice text={t('Loading sessions...')} />
|
||||
) : filteredSessions.length === 0 ? (
|
||||
<CenteredNotice
|
||||
text={
|
||||
searchQuery !== ''
|
||||
? t('No sessions match "{{query}}"', { query: searchQuery })
|
||||
: filterByBranch
|
||||
? t('No sessions found for branch "{{branch}}"', {
|
||||
branch: currentBranch ?? '',
|
||||
})
|
||||
: t('No sessions found')
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
visibleSessions.map((session, visibleIndex) => {
|
||||
const isDisabled = disabledIdSet.has(session.sessionId);
|
||||
return (
|
||||
<SessionRow
|
||||
key={session.sessionId}
|
||||
session={session}
|
||||
isSelected={
|
||||
!isSearchActive &&
|
||||
selectedIndex === scrollOffset + visibleIndex
|
||||
}
|
||||
isFirst={visibleIndex === 0}
|
||||
isLast={visibleIndex === visibleSessions.length - 1}
|
||||
showScrollUp={showScrollUp}
|
||||
showScrollDown={showScrollDown}
|
||||
promptWidth={Math.max(1, maxPromptWidth - checkboxWidth)}
|
||||
isChecked={
|
||||
enableMultiSelect
|
||||
? checkedIds.has(session.sessionId)
|
||||
: undefined
|
||||
}
|
||||
isDisabled={enableMultiSelect && isDisabled}
|
||||
disabledHint={
|
||||
enableMultiSelect && isDisabled
|
||||
? t('current — cannot delete')
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</box>
|
||||
|
||||
<Rule width={boxWidth} />
|
||||
|
||||
<box flexDirection="row" paddingLeft={1} paddingRight={1}>
|
||||
{isSearchActive ? (
|
||||
<text fg={C.dim}>
|
||||
{t('Type to search · Enter to commit · Esc to clear')}
|
||||
</text>
|
||||
) : (
|
||||
<text fg={C.dim}>
|
||||
{currentBranch ? (
|
||||
<span
|
||||
fg={filterByBranch ? C.accent : C.dim}
|
||||
attributes={filterByBranch ? 1 : undefined}
|
||||
>
|
||||
{'Ctrl+B'}
|
||||
</span>
|
||||
) : null}
|
||||
{footerTail}
|
||||
</text>
|
||||
)}
|
||||
</box>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
function Rule({ width }: { width: number }) {
|
||||
return (
|
||||
<box>
|
||||
<text fg={C.borderDefault}>{'─'.repeat(Math.max(0, width - 2))}</text>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
function CenteredNotice({ text, color }: { text: string; color?: string }) {
|
||||
return (
|
||||
<box flexDirection="row" justifyContent="center" paddingY={1}>
|
||||
<text fg={color ?? C.dim}>{text}</text>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
interface SessionRowProps {
|
||||
session: SessionListItem;
|
||||
isSelected: boolean;
|
||||
isFirst: boolean;
|
||||
isLast: boolean;
|
||||
showScrollUp: boolean;
|
||||
showScrollDown: boolean;
|
||||
promptWidth: number;
|
||||
/** `undefined` renders no checkbox column at all. */
|
||||
isChecked?: boolean;
|
||||
isDisabled: boolean;
|
||||
disabledHint?: string;
|
||||
}
|
||||
|
||||
function SessionRow({
|
||||
session,
|
||||
isSelected,
|
||||
isFirst,
|
||||
isLast,
|
||||
showScrollUp,
|
||||
showScrollDown,
|
||||
promptWidth,
|
||||
isChecked,
|
||||
isDisabled,
|
||||
disabledHint,
|
||||
}: SessionRowProps) {
|
||||
const showUpIndicator = isFirst && showScrollUp;
|
||||
const showDownIndicator = isLast && showScrollDown;
|
||||
const marker = isSelected
|
||||
? '› '
|
||||
: showUpIndicator
|
||||
? '↑ '
|
||||
: showDownIndicator
|
||||
? '↓ '
|
||||
: ' ';
|
||||
const markerColor = isSelected
|
||||
? C.accent
|
||||
: showUpIndicator || showDownIndicator
|
||||
? C.dim
|
||||
: C.text;
|
||||
|
||||
const promptText =
|
||||
session.customTitle ||
|
||||
session.prompt ||
|
||||
session.goalObjective ||
|
||||
'(empty prompt)';
|
||||
// Dim auto-generated titles so a model guess is distinguishable from a title
|
||||
// the user chose with /rename. The focused row keeps the accent color —
|
||||
// legibility of the selection wins over source hinting.
|
||||
const isAutoTitle =
|
||||
session.titleSource === 'auto' && Boolean(session.customTitle);
|
||||
const titleColor = isDisabled
|
||||
? C.dim
|
||||
: isSelected
|
||||
? C.accent
|
||||
: isAutoTitle
|
||||
? C.dim
|
||||
: C.text;
|
||||
|
||||
// `messageCount` is optional on the list entry because counting needs a full
|
||||
// pass over the JSONL; the row omits the segment when it isn't known.
|
||||
const messageText =
|
||||
typeof session.messageCount === 'number'
|
||||
? formatMessageCount(session.messageCount)
|
||||
: undefined;
|
||||
const meta = `${formatRelativeTime(session.mtime)}${
|
||||
messageText !== undefined ? ` · ${messageText}` : ''
|
||||
}${session.gitBranch ? ` · ${session.gitBranch}` : ''}${
|
||||
isDisabled && disabledHint ? ` · ${disabledHint}` : ''
|
||||
}`;
|
||||
|
||||
return (
|
||||
<box flexDirection="column" marginBottom={isLast ? 0 : 1}>
|
||||
<box flexDirection="row">
|
||||
<box width={2} flexShrink={0}>
|
||||
<text fg={markerColor}>{marker}</text>
|
||||
</box>
|
||||
{isChecked !== undefined && (
|
||||
<box width={4} flexShrink={0}>
|
||||
<text
|
||||
fg={
|
||||
isDisabled ? C.dim : isChecked || isSelected ? C.accent : C.dim
|
||||
}
|
||||
attributes={isChecked ? 1 : undefined}
|
||||
>
|
||||
{isChecked ? '[x] ' : '[ ] '}
|
||||
</text>
|
||||
</box>
|
||||
)}
|
||||
<box flexGrow={1}>
|
||||
<text
|
||||
fg={titleColor}
|
||||
attributes={isSelected && !isDisabled ? 1 : undefined}
|
||||
>
|
||||
{truncateText(promptText, promptWidth)}
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
<box paddingLeft={2}>
|
||||
<text fg={C.dim}>{meta}</text>
|
||||
</box>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
@@ -179,7 +179,7 @@ export type RewindAction =
|
||||
| { type: 'select-up' }
|
||||
| { type: 'select-down' }
|
||||
| { type: 'enter-pick'; fileCheckpointingEnabled: boolean }
|
||||
| { type: 'option-up' }
|
||||
| { type: 'option-up'; optionCount: number }
|
||||
| { type: 'option-down'; optionCount: number }
|
||||
| { type: 'back' }
|
||||
| { type: 'begin-restore' }
|
||||
@@ -196,6 +196,19 @@ export function createRewindState(turnCount: number): RewindState {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* One clamped step of the restore-option cursor. Shared by the reducer and by
|
||||
* the key handler, which has to know where a burst of arrows landed before
|
||||
* React has re-rendered the reducer's answer.
|
||||
*/
|
||||
export function stepRestoreOption(
|
||||
current: number,
|
||||
delta: -1 | 1,
|
||||
optionCount: number,
|
||||
): number {
|
||||
return Math.min(Math.max(0, optionCount - 1), Math.max(0, current + delta));
|
||||
}
|
||||
|
||||
export function rewindReducer(
|
||||
state: RewindState,
|
||||
action: RewindAction,
|
||||
@@ -225,16 +238,21 @@ export function rewindReducer(
|
||||
if (state.phase !== 'restore-options') return state;
|
||||
return {
|
||||
...state,
|
||||
restoreOptionIndex: Math.max(0, state.restoreOptionIndex - 1),
|
||||
restoreOptionIndex: stepRestoreOption(
|
||||
state.restoreOptionIndex,
|
||||
-1,
|
||||
action.optionCount,
|
||||
),
|
||||
};
|
||||
}
|
||||
case 'option-down': {
|
||||
if (state.phase !== 'restore-options') return state;
|
||||
return {
|
||||
...state,
|
||||
restoreOptionIndex: Math.min(
|
||||
Math.max(0, action.optionCount - 1),
|
||||
state.restoreOptionIndex + 1,
|
||||
restoreOptionIndex: stepRestoreOption(
|
||||
state.restoreOptionIndex,
|
||||
1,
|
||||
action.optionCount,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
/** @jsxImportSource @opentui/react */
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* Burst safety of the rewind restore-option cursor. The renderer hands every
|
||||
* key of one stdin read to the handler the previous render registered, so an
|
||||
* arrow auto-repeat followed by Enter used to restore whatever was highlighted
|
||||
* before the burst rather than what the arrows landed on.
|
||||
*/
|
||||
|
||||
import { act, render, screen } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
interface RawKey {
|
||||
name?: string;
|
||||
sequence?: string;
|
||||
ctrl?: boolean;
|
||||
meta?: boolean;
|
||||
option?: boolean;
|
||||
super?: boolean;
|
||||
shift?: boolean;
|
||||
paste?: boolean;
|
||||
}
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const state = {
|
||||
keyboardHandlers: [] as Array<(key: RawKey) => void>,
|
||||
width: 100,
|
||||
};
|
||||
async function buildJsxRuntime() {
|
||||
const React = await import('react');
|
||||
const jsx = (
|
||||
type: unknown,
|
||||
props: { children?: unknown } | null,
|
||||
key?: React.Key,
|
||||
) => {
|
||||
const config = key === undefined ? props : { ...props, key };
|
||||
const children = (config?.children ?? null) as React.ReactNode;
|
||||
if (type === 'box' || type === 'text') {
|
||||
return React.createElement(
|
||||
type === 'box' ? 'div' : 'span',
|
||||
key === undefined ? null : { key },
|
||||
children,
|
||||
);
|
||||
}
|
||||
return React.createElement(
|
||||
type as React.ElementType,
|
||||
config as Record<string, unknown>,
|
||||
children,
|
||||
);
|
||||
};
|
||||
return { jsx, jsxs: jsx, jsxDEV: jsx, Fragment: React.Fragment };
|
||||
}
|
||||
return { state, buildJsxRuntime };
|
||||
});
|
||||
|
||||
vi.mock('@opentui/react', async () => {
|
||||
const React = await import('react');
|
||||
return {
|
||||
useKeyboard: (handler: (key: RawKey) => void) => {
|
||||
const latest = React.useRef(handler);
|
||||
latest.current = handler;
|
||||
const stable = React.useRef<((key: RawKey) => void) | undefined>(
|
||||
undefined,
|
||||
);
|
||||
if (!stable.current) {
|
||||
stable.current = (key: RawKey) => latest.current(key);
|
||||
mocks.state.keyboardHandlers.push(stable.current);
|
||||
}
|
||||
},
|
||||
useTerminalDimensions: () => ({ width: mocks.state.width, height: 40 }),
|
||||
};
|
||||
});
|
||||
vi.mock('@opentui/core', () => ({
|
||||
SyntaxStyle: { fromStyles: () => ({}) },
|
||||
MouseButton: { LEFT: 0 },
|
||||
}));
|
||||
vi.mock('@opentui/react/jsx-runtime', () => mocks.buildJsxRuntime());
|
||||
vi.mock('@opentui/react/jsx-dev-runtime', () => mocks.buildJsxRuntime());
|
||||
vi.mock('./key-map.js', () => ({
|
||||
toOriginalKey: (key: RawKey) => ({
|
||||
name: key.name ?? '',
|
||||
ctrl: !!key.ctrl,
|
||||
meta: !!(key.meta || key.option || key.super),
|
||||
shift: !!key.shift,
|
||||
paste: !!key.paste,
|
||||
sequence: key.sequence ?? '',
|
||||
}),
|
||||
}));
|
||||
|
||||
import { OpentuiRewindSelector, type RewindTurn } from './session-rewind.js';
|
||||
|
||||
/** One stdin read: every key goes to the same handler closure, no render between. */
|
||||
function burst(keys: RawKey[]) {
|
||||
if (mocks.state.keyboardHandlers.length === 0) {
|
||||
throw new Error('no keyboard handler registered');
|
||||
}
|
||||
act(() => {
|
||||
for (const key of keys) {
|
||||
for (const handler of [...mocks.state.keyboardHandlers]) {
|
||||
handler({ ...key });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function flush() {
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
}
|
||||
|
||||
const TURNS: RewindTurn[] = [
|
||||
{ id: 't1', text: 'First prompt', sentToModel: true },
|
||||
{ id: 't2', text: 'Second prompt', sentToModel: true },
|
||||
];
|
||||
|
||||
const DOWN = { name: 'down' } as RawKey;
|
||||
const ENTER = { name: 'return', sequence: '\r' } as RawKey;
|
||||
|
||||
function renderSelector(onRewind = vi.fn()) {
|
||||
const onCancel = vi.fn();
|
||||
render(
|
||||
<OpentuiRewindSelector
|
||||
turns={TURNS}
|
||||
fileCheckpointingEnabled={true}
|
||||
getDiffStats={vi.fn().mockResolvedValue({
|
||||
filesChanged: ['a.ts'],
|
||||
insertions: 3,
|
||||
deletions: 1,
|
||||
})}
|
||||
onRewind={onRewind}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
);
|
||||
return { onRewind, onCancel };
|
||||
}
|
||||
|
||||
/** The label on the row carrying ink's `›` cursor. */
|
||||
function highlighted(): string {
|
||||
const rows = Array.from(document.querySelectorAll('span'))
|
||||
.map((el) => el.textContent ?? '')
|
||||
.filter((text) => text.startsWith('›'));
|
||||
expect(rows).toHaveLength(1);
|
||||
return rows[0]!;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.state.keyboardHandlers.length = 0;
|
||||
mocks.state.width = 100;
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
describe('OpentuiRewindSelector restore-option burst', () => {
|
||||
it('restores the option a burst of arrows landed on', async () => {
|
||||
const { onRewind } = renderSelector();
|
||||
burst([ENTER]);
|
||||
await flush();
|
||||
// both / conversation / code / cancel — the diff has a changed file.
|
||||
expect(screen.getByText('Restore code only')).toBeTruthy();
|
||||
expect(highlighted()).toMatch(/^› Restore code and conversation/);
|
||||
|
||||
burst([DOWN, DOWN, ENTER]);
|
||||
expect(onRewind).toHaveBeenCalledTimes(1);
|
||||
expect(onRewind.mock.calls[0]![1]).toBe('code');
|
||||
expect(onRewind.mock.calls[0]![0]).toEqual(TURNS[1]);
|
||||
});
|
||||
|
||||
it('re-syncs the cursor after a burst that clamped into "Never mind"', async () => {
|
||||
const { onRewind, onCancel } = renderSelector();
|
||||
burst([ENTER]);
|
||||
await flush();
|
||||
|
||||
// Five downs over four options clamp onto the last one, which cancels.
|
||||
burst([DOWN, DOWN, DOWN, DOWN, DOWN, ENTER]);
|
||||
expect(onRewind).not.toHaveBeenCalled();
|
||||
expect(onCancel).not.toHaveBeenCalled();
|
||||
expect(screen.getByText(/turns\)$/)).toBeTruthy();
|
||||
|
||||
// Back on the pick list the reducer reset the cursor, and the handler's
|
||||
// own mirror has to follow it or the next burst starts from index 3.
|
||||
burst([ENTER]);
|
||||
await flush();
|
||||
expect(highlighted()).toMatch(/^› Restore code and conversation/);
|
||||
burst([DOWN, ENTER]);
|
||||
expect(onRewind).toHaveBeenCalledTimes(1);
|
||||
expect(onRewind.mock.calls[0]![1]).toBe('conversation');
|
||||
});
|
||||
|
||||
it('ignores keys while the diff is still loading', async () => {
|
||||
const { onRewind } = renderSelector();
|
||||
burst([ENTER]);
|
||||
expect(screen.getByText('Computing file changes...')).toBeTruthy();
|
||||
burst([DOWN, ENTER]);
|
||||
expect(onRewind).not.toHaveBeenCalled();
|
||||
await flush();
|
||||
burst([ENTER]);
|
||||
expect(onRewind.mock.calls[0]![1]).toBe('both');
|
||||
});
|
||||
});
|
||||
@@ -211,7 +211,7 @@ describe('session-rewind state machine', () => {
|
||||
type: 'enter-pick',
|
||||
fileCheckpointingEnabled: true,
|
||||
});
|
||||
state = rewindReducer(state, { type: 'option-up' });
|
||||
state = rewindReducer(state, { type: 'option-up', optionCount: 4 });
|
||||
expect(state.restoreOptionIndex).toBe(0);
|
||||
state = rewindReducer(state, { type: 'option-down', optionCount: 4 });
|
||||
state = rewindReducer(state, { type: 'option-down', optionCount: 4 });
|
||||
|
||||
@@ -52,6 +52,7 @@ import {
|
||||
buildRestoreOptions,
|
||||
rewindReducer,
|
||||
createRewindState,
|
||||
stepRestoreOption,
|
||||
} from './session-rewind-model.js';
|
||||
|
||||
export interface OpentuiRewindSelectorProps {
|
||||
@@ -84,6 +85,13 @@ export function OpentuiRewindSelector(props: OpentuiRewindSelectorProps) {
|
||||
? null
|
||||
: (userTurns[state.selectedTurnIndex] ?? null);
|
||||
const restoreOptions = buildRestoreOptions(diffStats);
|
||||
// The renderer gives every key of one burst to the handler the last render
|
||||
// registered, so Enter must not read the reducer's pre-burst index: an
|
||||
// arrow auto-repeat followed by Enter would restore the wrong thing. The
|
||||
// handler advances this itself and the render below keeps it in step with
|
||||
// the resets the reducer performs.
|
||||
const restoreOptionRef = useRef(state.restoreOptionIndex);
|
||||
restoreOptionRef.current = state.restoreOptionIndex;
|
||||
|
||||
useEffect(() => {
|
||||
if (state.phase !== 'restore-options' || !selectedTurn) return;
|
||||
@@ -166,7 +174,7 @@ export function OpentuiRewindSelector(props: OpentuiRewindSelectorProps) {
|
||||
}
|
||||
if (loadingDiff) return;
|
||||
if (original.name === 'return') {
|
||||
const option = restoreOptions[state.restoreOptionIndex];
|
||||
const option = restoreOptions[restoreOptionRef.current];
|
||||
if (!option || !selectedTurn) return;
|
||||
if (option.key === 'cancel') {
|
||||
setDiffStats(undefined);
|
||||
@@ -176,15 +184,23 @@ export function OpentuiRewindSelector(props: OpentuiRewindSelectorProps) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
const stepOption = (delta: -1 | 1) => {
|
||||
restoreOptionRef.current = stepRestoreOption(
|
||||
restoreOptionRef.current,
|
||||
delta,
|
||||
restoreOptions.length,
|
||||
);
|
||||
dispatch({
|
||||
type: delta === -1 ? 'option-up' : 'option-down',
|
||||
optionCount: restoreOptions.length,
|
||||
});
|
||||
};
|
||||
if (original.name === 'up' || original.name === 'k') {
|
||||
dispatch({ type: 'option-up' });
|
||||
stepOption(-1);
|
||||
return;
|
||||
}
|
||||
if (original.name === 'down' || original.name === 'j') {
|
||||
dispatch({
|
||||
type: 'option-down',
|
||||
optionCount: restoreOptions.length,
|
||||
});
|
||||
stepOption(1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -240,7 +256,7 @@ export function OpentuiRewindSelector(props: OpentuiRewindSelectorProps) {
|
||||
<text fg={C.text} attributes={1}>
|
||||
{t('Rewind Conversation')}
|
||||
</text>
|
||||
<text fg={C.dim}>{separator}</text>
|
||||
<text fg={C.borderDefault}>{separator}</text>
|
||||
<box flexDirection="column">
|
||||
<box marginBottom={1} flexDirection="row">
|
||||
<text fg={C.text}>{t('Rewind to: ')}</text>
|
||||
@@ -254,7 +270,7 @@ export function OpentuiRewindSelector(props: OpentuiRewindSelectorProps) {
|
||||
)}
|
||||
</text>
|
||||
</box>
|
||||
<text fg={C.dim}>{separator}</text>
|
||||
<text fg={C.borderDefault}>{separator}</text>
|
||||
<text fg={C.dim}>{t('Enter/Y to confirm · Esc/N to go back')}</text>
|
||||
</DialogFrame>
|
||||
);
|
||||
@@ -268,7 +284,7 @@ export function OpentuiRewindSelector(props: OpentuiRewindSelectorProps) {
|
||||
<text fg={C.text} attributes={1}>
|
||||
{t('Rewind Conversation')}
|
||||
</text>
|
||||
<text fg={C.dim}>{separator}</text>
|
||||
<text fg={C.borderDefault}>{separator}</text>
|
||||
<box flexDirection="column">
|
||||
<box marginBottom={1} flexDirection="row">
|
||||
<text fg={C.text}>{t('Rewind to: ')}</text>
|
||||
@@ -313,7 +329,7 @@ export function OpentuiRewindSelector(props: OpentuiRewindSelectorProps) {
|
||||
</box>
|
||||
)}
|
||||
</box>
|
||||
<text fg={C.dim}>{separator}</text>
|
||||
<text fg={C.borderDefault}>{separator}</text>
|
||||
<text fg={C.dim}>
|
||||
{t('↑↓ to navigate · Enter to select · Esc to go back')}
|
||||
</text>
|
||||
@@ -342,7 +358,7 @@ export function OpentuiRewindSelector(props: OpentuiRewindSelectorProps) {
|
||||
{t('({{count}} turns)', { count: String(userTurns.length) })}
|
||||
</text>
|
||||
</box>
|
||||
<text fg={C.dim}>{separator}</text>
|
||||
<text fg={C.borderDefault}>{separator}</text>
|
||||
<box flexDirection="column">
|
||||
{visibleTurns.map((turn, visibleIndex) => {
|
||||
const actualIndex = window_.offset + visibleIndex;
|
||||
@@ -382,7 +398,7 @@ export function OpentuiRewindSelector(props: OpentuiRewindSelectorProps) {
|
||||
);
|
||||
})}
|
||||
</box>
|
||||
<text fg={C.dim}>{separator}</text>
|
||||
<text fg={C.borderDefault}>{separator}</text>
|
||||
<text fg={C.dim}>
|
||||
{t('↑↓ to navigate · Enter to select · Esc to cancel')}
|
||||
</text>
|
||||
|
||||
@@ -19,6 +19,8 @@ const mocks = vi.hoisted(() => {
|
||||
const state = {
|
||||
renderer: {
|
||||
destroy: vi.fn(),
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
},
|
||||
root: {
|
||||
render: vi.fn(),
|
||||
@@ -74,10 +76,12 @@ vi.mock('@opentui/core', () => ({
|
||||
}),
|
||||
SyntaxStyle: { fromStyles: () => ({}) },
|
||||
MouseButton: { LEFT: 0 },
|
||||
CliRenderEvents: { FOCUS: 'focus', BLUR: 'blur' },
|
||||
}));
|
||||
vi.mock('@opentui/react', () => ({
|
||||
createRoot: vi.fn(() => mocks.state.root),
|
||||
useKeyboard: () => {},
|
||||
useRenderer: () => mocks.state.renderer,
|
||||
useTerminalDimensions: () => ({ width: 120, height: 40 }),
|
||||
}));
|
||||
vi.mock('@opentui/react/jsx-runtime', () => mocks.buildJsxRuntime());
|
||||
@@ -322,7 +326,13 @@ describe('startOpenTuiUI fallback contract', () => {
|
||||
const started = await startOpenTuiUI(
|
||||
buildConfig(),
|
||||
{
|
||||
merged: { ui: { hideWindowTitle: true, showToolCallArgs: true } },
|
||||
merged: {
|
||||
ui: {
|
||||
hideWindowTitle: true,
|
||||
showToolCallArgs: true,
|
||||
mouseTracking: false,
|
||||
},
|
||||
},
|
||||
} as unknown as LoadedSettings,
|
||||
[],
|
||||
'/tmp/project',
|
||||
@@ -330,8 +340,13 @@ describe('startOpenTuiUI fallback contract', () => {
|
||||
);
|
||||
expect(started).toBe(true);
|
||||
|
||||
// The entry mounts useTerminalFocus, which writes the ?1004 mode escape.
|
||||
const write = vi
|
||||
.spyOn(process.stdout, 'write')
|
||||
.mockImplementation(() => true);
|
||||
const calls = mocks.state.root.render.mock.calls;
|
||||
render(calls[calls.length - 1]?.[0] as ReactElement);
|
||||
write.mockRestore();
|
||||
// renderMain draws the transcript inside a box, and the mocked JSX
|
||||
// runtime turns that box into a plain element, so the transcript's own
|
||||
// props sit one level below the returned element.
|
||||
@@ -341,6 +356,11 @@ describe('startOpenTuiUI fallback contract', () => {
|
||||
const props = main?.props?.children?.props;
|
||||
expect(props?.['awaitingCallId']).toBe('q1');
|
||||
expect(props?.['showToolCallArgs']).toBe(true);
|
||||
expect(props?.['mouseTracking']).toBe(false);
|
||||
// The renderer takes no pointer events when the setting is off.
|
||||
expect(createCliRenderer).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ useMouse: false }),
|
||||
);
|
||||
});
|
||||
|
||||
it('warms the shell AST parser before the renderer is created', async () => {
|
||||
|
||||
@@ -70,10 +70,14 @@ import {
|
||||
} from '../contexts/SessionContext.js';
|
||||
import { MessageType, type HistoryItemWithoutId } from '../types.js';
|
||||
import { useUpdateNoticeFlush } from './use-update-notice-flush.js';
|
||||
import { applyOpenTuiTheme } from './theme.js';
|
||||
import { getActiveOpenTuiTheme } from './theme-parity.js';
|
||||
import { useLogger } from '../hooks/useLogger.js';
|
||||
import { useAwaySummary } from '../hooks/useAwaySummary.js';
|
||||
import type { UpdateObject } from '../utils/updateCheck.js';
|
||||
import { OpenTuiApp } from './opentui-app-shell.js';
|
||||
import { useFollowupSuggestionGeneration } from './followup-generation.js';
|
||||
import { useTerminalFocus } from './focus-tracking.js';
|
||||
import type { OpenTuiDialogRequest } from './commands-registry.js';
|
||||
import { OpenTuiRuntime } from './opentui-runtime.js';
|
||||
import { OpenTuiTranscriptView } from './transcript-view.js';
|
||||
@@ -136,6 +140,42 @@ function OpenTuiEntryApp({
|
||||
waitingCalls: live.waitingCalls,
|
||||
});
|
||||
|
||||
// --- away recap (ink AppContainer's useAwaySummary parity) ----------------
|
||||
// The entry owns the live transcript and the streaming→idle edge, so the
|
||||
// hook mounts here rather than in the shell. The gate reads only
|
||||
// {type, sentToModel}, which the live rows carry under `kind`.
|
||||
const isFocused = useTerminalFocus();
|
||||
const recapActivity = useMemo(
|
||||
() =>
|
||||
live.items.flatMap((item) =>
|
||||
item.kind === 'user'
|
||||
? [{ type: 'user', sentToModel: item.sentToModel }]
|
||||
: item.kind === 'away-recap'
|
||||
? [{ type: 'away_recap' }]
|
||||
: [],
|
||||
),
|
||||
[live.items],
|
||||
);
|
||||
const addRecapItem = useCallback(
|
||||
(item: HistoryItemWithoutId) => {
|
||||
if (item.type === 'away_recap') {
|
||||
applyEvent({ type: 'away-recap', text: item.text });
|
||||
}
|
||||
return 0;
|
||||
},
|
||||
[applyEvent],
|
||||
);
|
||||
useAwaySummary({
|
||||
enabled: settings.merged.general?.showSessionRecap ?? false,
|
||||
config,
|
||||
isFocused,
|
||||
isIdle: !live.streaming,
|
||||
addItem: addRecapItem,
|
||||
history: recapActivity,
|
||||
awayThresholdMinutes:
|
||||
settings.merged.general?.sessionRecapAwayThresholdMinutes,
|
||||
});
|
||||
|
||||
const statsRef = useRef(stats);
|
||||
useEffect(() => {
|
||||
statsRef.current = stats;
|
||||
@@ -247,6 +287,11 @@ function OpenTuiEntryApp({
|
||||
availableTerminalHeight={height}
|
||||
thoughtsExpanded={thoughtsExpanded}
|
||||
showToolCallArgs={settings.merged.ui?.showToolCallArgs === true}
|
||||
showTimestamps={settings.merged.output?.showTimestamps === true}
|
||||
showToolCallDetails={
|
||||
settings.merged.ui?.showToolCallDetails !== false
|
||||
}
|
||||
mouseTracking={settings.merged.ui?.mouseTracking !== false}
|
||||
awaitingCallId={live.waitingCalls[0]?.callId}
|
||||
/>
|
||||
</box>
|
||||
@@ -348,7 +393,12 @@ export async function startOpenTuiUI(
|
||||
|
||||
let renderer: CliRenderer;
|
||||
try {
|
||||
renderer = await createCliRenderer({ exitOnCtrlC: false, useMouse: true });
|
||||
renderer = await createCliRenderer({
|
||||
exitOnCtrlC: false,
|
||||
// ink's ui.mouseTracking gate: off means no SGR mouse mode, so the
|
||||
// terminal keeps native right-click menus and OSC 8 link clicks.
|
||||
useMouse: settings.merged.ui?.mouseTracking !== false,
|
||||
});
|
||||
} catch (err) {
|
||||
debugLogger.error('OpenTUI renderer initialization failed:', err);
|
||||
writeStderrLine(
|
||||
@@ -357,6 +407,10 @@ export async function startOpenTuiUI(
|
||||
return false;
|
||||
}
|
||||
|
||||
// llm.tsx drained the deferred auto-theme probe before dispatching here, so
|
||||
// the active ink theme is final; mapping it once is enough for first paint.
|
||||
applyOpenTuiTheme(getActiveOpenTuiTheme());
|
||||
|
||||
// Everything past renderer creation is also fallible (sidecar I/O, render,
|
||||
// prefetch wiring); a rejection must tear the renderer down and fall back
|
||||
// to ink instead of crashing the process (B2).
|
||||
|
||||
@@ -87,8 +87,9 @@ describe('palette parity (semantic tokens → opentui palette)', () => {
|
||||
it('maps Qwen Dark through its semantic tokens with the hljs default text', () => {
|
||||
const definition = getOpenTuiTheme('Qwen Dark');
|
||||
expect(definition).toBeDefined();
|
||||
// Semantic text.primary is empty for this theme; ink falls back to the
|
||||
// theme default color, so opentui must too.
|
||||
// Semantic text.primary is empty for this theme and ink then emits no
|
||||
// foreground at all; the fallback is this port's own, because both its
|
||||
// plain-text colour and the markdown syntax `default` token read this key.
|
||||
expect(definition!.palette.text).toBe('#bfbdb6');
|
||||
expect(definition!.palette.dim).toBe('#6C7086');
|
||||
expect(definition!.palette.accent).toBe('#CBA6F7');
|
||||
@@ -108,7 +109,12 @@ describe('palette parity (semantic tokens → opentui palette)', () => {
|
||||
green: '#50fa7b',
|
||||
red: '#ff5555',
|
||||
yellow: '#fff783',
|
||||
warningDim: '#8B7530',
|
||||
errorDim: '#8B3A4A',
|
||||
purple: '#8be9fd',
|
||||
symbol: '#8be9fd',
|
||||
borderFocused: '#8be9fd',
|
||||
borderDefault: '#6272a4',
|
||||
hover: '#282a36',
|
||||
});
|
||||
});
|
||||
@@ -125,6 +131,47 @@ describe('palette parity (semantic tokens → opentui palette)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('gradient parity (ui.gradient → wordmark ramp)', () => {
|
||||
it('carries the ramp ink resolves, at any stop count', () => {
|
||||
expect(getOpenTuiTheme('Dracula')!.gradient).toEqual([
|
||||
'#ff79c6',
|
||||
'#8be9fd',
|
||||
]);
|
||||
expect(getOpenTuiTheme('Shades Of Purple')!.gradient).toEqual([
|
||||
'#4d21fc',
|
||||
'#847ace',
|
||||
'#ff628c',
|
||||
]);
|
||||
});
|
||||
|
||||
it('inherits the semantic ramp for themes that share a semantic set', () => {
|
||||
// Qwen Dark declares its own gold GradientColors, but it is constructed
|
||||
// with darkSemanticColors, whose `ui.gradient` is the dark base ramp — so
|
||||
// that is what ink's Header paints, and what the port must paint too.
|
||||
expect(getOpenTuiTheme('Qwen Dark')!.gradient).toEqual([
|
||||
'#4796E4',
|
||||
'#847ACE',
|
||||
'#C3677F',
|
||||
]);
|
||||
expect(getOpenTuiTheme('ANSI')!.gradient).toEqual([
|
||||
'#4796E4',
|
||||
'#847ACE',
|
||||
'#C3677F',
|
||||
]);
|
||||
});
|
||||
|
||||
it('yields no ramp for NoColor, so the logo renders uncolored as in ink', () => {
|
||||
const previous = process.env['NO_COLOR'];
|
||||
process.env['NO_COLOR'] = '1';
|
||||
try {
|
||||
expect(getActiveOpenTuiTheme().gradient).toEqual([]);
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env['NO_COLOR'];
|
||||
else process.env['NO_COLOR'] = previous;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('syntax style parity (hljs map → opentui tokens)', () => {
|
||||
it('uses each theme’s own resolved hljs colors', () => {
|
||||
// Ink resolves hljs colors through `resolveColor`, which lowercases hex.
|
||||
|
||||
@@ -18,12 +18,15 @@ import { SyntaxStyle, type StyleDefinitionInput } from '@opentui/core';
|
||||
import type { Palette } from './theme.js';
|
||||
import type { Theme, ThemeType } from '../themes/theme.js';
|
||||
import { themeManager } from '../themes/theme-manager.js';
|
||||
import { getRenderableGradientColors } from '../utils/gradientUtils.js';
|
||||
|
||||
export interface OpenTuiThemeDefinition {
|
||||
name: string;
|
||||
type: ThemeType;
|
||||
palette: Palette;
|
||||
syntaxStyles: Record<string, StyleDefinitionInput>;
|
||||
/** Banner wordmark ramp; empty means ink renders the logo uncolored. */
|
||||
gradient: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -46,9 +49,10 @@ export const HLJS_TO_SYNTAX_TOKEN: ReadonlyArray<readonly [string, string]> = [
|
||||
|
||||
/**
|
||||
* Derives the OpenTUI palette from an ink theme's semantic tokens. Empty
|
||||
* strings are preserved — they mean "no color" in ink and must stay unset in
|
||||
* opentui (e.g. the NoColor theme, or themes with an empty Foreground that
|
||||
* rely on the terminal default).
|
||||
* strings mean "no color" in ink and are passed through unset (e.g. the
|
||||
* NoColor theme), except for `text`: this port anchors both its plain-text
|
||||
* colour and the markdown syntax `default` token on that key, so a theme whose
|
||||
* primary is empty falls back to its own hljs foreground.
|
||||
*/
|
||||
export function paletteFromInkTheme(theme: Theme): Palette {
|
||||
const semantic = theme.semanticColors;
|
||||
@@ -59,11 +63,33 @@ export function paletteFromInkTheme(theme: Theme): Palette {
|
||||
green: semantic.status.success,
|
||||
red: semantic.status.error,
|
||||
yellow: semantic.status.warning,
|
||||
warningDim: semantic.status.warningDim,
|
||||
errorDim: semantic.status.errorDim,
|
||||
purple: semantic.text.link,
|
||||
symbol: semantic.ui.symbol,
|
||||
borderFocused: semantic.border.focused,
|
||||
borderDefault: semantic.border.default,
|
||||
hover: semantic.background.primary,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The banner wordmark ramp, resolved exactly as the ink `Header` does:
|
||||
* `ui.gradient` when the theme declares one, otherwise the
|
||||
* `[secondary, link, accent]` fallback, otherwise no gradient at all (ink
|
||||
* then renders the logo uncolored rather than throwing).
|
||||
*/
|
||||
export function gradientFromInkTheme(theme: Theme): string[] {
|
||||
const semantic = theme.semanticColors;
|
||||
return (
|
||||
getRenderableGradientColors(semantic.ui.gradient, [
|
||||
semantic.text.secondary,
|
||||
semantic.text.link,
|
||||
semantic.text.accent,
|
||||
]) ?? []
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives the opentui `SyntaxStyle.fromStyles` input from an ink theme's
|
||||
* resolved hljs color map. Emphasis/strong keep their fixed markdown
|
||||
@@ -99,6 +125,7 @@ export function openTuiThemeFromInkTheme(theme: Theme): OpenTuiThemeDefinition {
|
||||
type: theme.type,
|
||||
palette: paletteFromInkTheme(theme),
|
||||
syntaxStyles: syntaxStylesFromInkTheme(theme),
|
||||
gradient: gradientFromInkTheme(theme),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -23,10 +23,13 @@ vi.mock('@opentui/core', () => ({
|
||||
|
||||
import {
|
||||
C,
|
||||
GRADIENT,
|
||||
SYNTAX,
|
||||
SYNTAX_DIM,
|
||||
applyOpenTuiTheme,
|
||||
applyThemeMode,
|
||||
markdownMarkupTokens,
|
||||
type Palette,
|
||||
} from './theme.js';
|
||||
import type { OpenTuiThemeDefinition } from './theme-parity.js';
|
||||
|
||||
@@ -34,6 +37,43 @@ function syntaxTokens(): Record<string, unknown> {
|
||||
return (SYNTAX as unknown as { styles: Record<string, unknown> }).styles;
|
||||
}
|
||||
|
||||
function dimSyntaxTokens(): Record<string, unknown> {
|
||||
return (SYNTAX_DIM as unknown as { styles: Record<string, unknown> }).styles;
|
||||
}
|
||||
|
||||
const PALETTE = {
|
||||
text: '#112233',
|
||||
dim: '#445566',
|
||||
accent: '#778899',
|
||||
green: '#00aa00',
|
||||
red: '#aa0000',
|
||||
yellow: '#aaaa00',
|
||||
warningDim: '#bb1100',
|
||||
errorDim: '#bb0011',
|
||||
purple: '#aa00aa',
|
||||
symbol: '#00aaaa',
|
||||
borderFocused: '#0011aa',
|
||||
borderDefault: '#1100aa',
|
||||
hover: '#010101',
|
||||
};
|
||||
|
||||
/** The NoColor theme maps every ink color to ''. */
|
||||
const EMPTY_PALETTE: Palette = {
|
||||
text: '',
|
||||
dim: '',
|
||||
accent: '',
|
||||
green: '',
|
||||
red: '',
|
||||
yellow: '',
|
||||
warningDim: '',
|
||||
errorDim: '',
|
||||
purple: '',
|
||||
symbol: '',
|
||||
borderFocused: '',
|
||||
borderDefault: '',
|
||||
hover: '',
|
||||
};
|
||||
|
||||
describe('markdownMarkupTokens', () => {
|
||||
it('covers the markdown renderable capture names', () => {
|
||||
for (const mode of ['dark', 'light'] as const) {
|
||||
@@ -80,23 +120,13 @@ describe('applyThemeMode', () => {
|
||||
});
|
||||
|
||||
describe('applyOpenTuiTheme (settings ui.theme face)', () => {
|
||||
const palette = {
|
||||
text: '#112233',
|
||||
dim: '#445566',
|
||||
accent: '#778899',
|
||||
green: '#00aa00',
|
||||
red: '#aa0000',
|
||||
yellow: '#aaaa00',
|
||||
purple: '#aa00aa',
|
||||
hover: '#010101',
|
||||
};
|
||||
|
||||
it('applies the mapped palette and keeps dark transparency', () => {
|
||||
applyOpenTuiTheme({
|
||||
name: 'Some Dark',
|
||||
type: 'dark',
|
||||
palette,
|
||||
palette: PALETTE,
|
||||
syntaxStyles: { keyword: { fg: '#abcdef' } },
|
||||
gradient: [],
|
||||
} satisfies OpenTuiThemeDefinition);
|
||||
expect(C.text).toBe('#112233');
|
||||
expect(C.hover).toBe('#010101');
|
||||
@@ -112,8 +142,9 @@ describe('applyOpenTuiTheme (settings ui.theme face)', () => {
|
||||
applyOpenTuiTheme({
|
||||
name: 'Some Light',
|
||||
type: 'light',
|
||||
palette,
|
||||
palette: PALETTE,
|
||||
syntaxStyles: {},
|
||||
gradient: [],
|
||||
} satisfies OpenTuiThemeDefinition);
|
||||
expect(C.bg).toBe('#FAFAFA');
|
||||
expect(syntaxTokens()['markup.raw']).toBeDefined();
|
||||
@@ -125,24 +156,16 @@ describe('applyOpenTuiTheme (settings ui.theme face)', () => {
|
||||
// The NoColor theme maps every ink color to ''. Downstream parseColor('')
|
||||
// is not "unset" — it falls back to magenta — so the empties must be
|
||||
// dropped and the built-in dark surface left in place.
|
||||
const empty = {
|
||||
text: '',
|
||||
dim: '',
|
||||
accent: '',
|
||||
green: '',
|
||||
red: '',
|
||||
yellow: '',
|
||||
purple: '',
|
||||
hover: '',
|
||||
};
|
||||
applyOpenTuiTheme({
|
||||
name: 'NoColor',
|
||||
type: 'dark',
|
||||
palette: empty,
|
||||
palette: EMPTY_PALETTE,
|
||||
syntaxStyles: {},
|
||||
gradient: [],
|
||||
} satisfies OpenTuiThemeDefinition);
|
||||
expect(C.text).toBe('#CDD6F4');
|
||||
expect(C.hover).toBe('#313244');
|
||||
expect(C.errorDim).toBe('#8B3A4A');
|
||||
expect(C.bg).toBeUndefined();
|
||||
expect(syntaxTokens()['default']).toBeUndefined();
|
||||
// Restore the dark default for other suites.
|
||||
@@ -155,8 +178,9 @@ describe('applyOpenTuiTheme (settings ui.theme face)', () => {
|
||||
applyOpenTuiTheme({
|
||||
name: 'Coral Dark',
|
||||
type: 'dark',
|
||||
palette: { ...palette, text: 'coral' },
|
||||
palette: { ...PALETTE, text: 'coral' },
|
||||
syntaxStyles: {},
|
||||
gradient: [],
|
||||
} satisfies OpenTuiThemeDefinition);
|
||||
expect(C.text).toBe('#ff7f50');
|
||||
// Restore the dark default for other suites.
|
||||
@@ -167,8 +191,9 @@ describe('applyOpenTuiTheme (settings ui.theme face)', () => {
|
||||
applyOpenTuiTheme({
|
||||
name: 'Odd Dark',
|
||||
type: 'dark',
|
||||
palette: { ...palette, text: 'not-a-color' },
|
||||
palette: { ...PALETTE, text: 'not-a-color' },
|
||||
syntaxStyles: { keyword: { fg: 'not-a-color' } },
|
||||
gradient: [],
|
||||
} satisfies OpenTuiThemeDefinition);
|
||||
expect(C.text).toBe('#CDD6F4');
|
||||
// The unresolvable style registered without a fg color.
|
||||
@@ -178,3 +203,88 @@ describe('applyOpenTuiTheme (settings ui.theme face)', () => {
|
||||
applyThemeMode('dark');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GRADIENT (banner wordmark ramp)', () => {
|
||||
it('resolves each stop through toHex, CSS names included', () => {
|
||||
// The ANSI themes declare ink color names; opentui's parseColor knows
|
||||
// only a small named table, so the stops must arrive as #rrggbb.
|
||||
applyOpenTuiTheme({
|
||||
name: 'Some Dark',
|
||||
type: 'dark',
|
||||
palette: PALETTE,
|
||||
syntaxStyles: {},
|
||||
gradient: ['cyan', '#FF79C6'],
|
||||
} satisfies OpenTuiThemeDefinition);
|
||||
expect(GRADIENT).toEqual(['#00ffff', '#ff79c6']);
|
||||
// Restore the dark default for other suites.
|
||||
applyThemeMode('dark');
|
||||
});
|
||||
|
||||
it('yields no ramp when fewer than two stops survive', () => {
|
||||
// ink renders the logo uncolored rather than painting a single stop.
|
||||
applyOpenTuiTheme({
|
||||
name: 'NoColor',
|
||||
type: 'dark',
|
||||
palette: EMPTY_PALETTE,
|
||||
syntaxStyles: {},
|
||||
gradient: ['not-a-color', '#ff0000'],
|
||||
} satisfies OpenTuiThemeDefinition);
|
||||
expect(GRADIENT).toEqual([]);
|
||||
// Restore the dark default for other suites.
|
||||
applyThemeMode('dark');
|
||||
});
|
||||
|
||||
it('restores the built-in ramp on a mode switch', () => {
|
||||
applyThemeMode('dark');
|
||||
expect(GRADIENT).toEqual(['#4796E4', '#847ACE', '#C3677F']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SYNTAX_DIM (thought body)', () => {
|
||||
it('dims plain text and headings, keeping inline code and links', () => {
|
||||
applyOpenTuiTheme({
|
||||
name: 'Some Dark',
|
||||
type: 'dark',
|
||||
palette: PALETTE,
|
||||
syntaxStyles: {},
|
||||
gradient: [],
|
||||
} satisfies OpenTuiThemeDefinition);
|
||||
// ink's ThinkBody paints the whole body with theme.text.secondary.
|
||||
expect(dimSyntaxTokens()['default']).toMatchObject({ fg: '#445566' });
|
||||
expect(dimSyntaxTokens()['markup.heading.1']).toMatchObject({
|
||||
fg: '#445566',
|
||||
bold: true,
|
||||
});
|
||||
// ink's InlineMarkdownRenderer keeps these on their own colors.
|
||||
expect(dimSyntaxTokens()['markup.raw']).toEqual(
|
||||
syntaxTokens()['markup.raw'],
|
||||
);
|
||||
expect(dimSyntaxTokens()['markup.link.url']).toEqual(
|
||||
syntaxTokens()['markup.link.url'],
|
||||
);
|
||||
// The main style still anchors plain text on the theme's foreground.
|
||||
expect(syntaxTokens()['default']).toMatchObject({ fg: '#112233' });
|
||||
// Restore the dark default for other suites.
|
||||
applyThemeMode('dark');
|
||||
});
|
||||
|
||||
it('follows the palette across a mode switch', () => {
|
||||
applyThemeMode('light');
|
||||
expect(dimSyntaxTokens()['default']).toMatchObject({ fg: '#97a0b0' });
|
||||
applyThemeMode('dark');
|
||||
expect(dimSyntaxTokens()['default']).toMatchObject({ fg: '#6C7086' });
|
||||
});
|
||||
|
||||
it('omits default when the theme does (NoColor)', () => {
|
||||
applyOpenTuiTheme({
|
||||
name: 'NoColor',
|
||||
type: 'dark',
|
||||
palette: EMPTY_PALETTE,
|
||||
syntaxStyles: {},
|
||||
gradient: [],
|
||||
} satisfies OpenTuiThemeDefinition);
|
||||
expect(dimSyntaxTokens()['default']).toBeUndefined();
|
||||
// Restore the dark default for other suites.
|
||||
applyThemeMode('dark');
|
||||
});
|
||||
});
|
||||
@@ -25,7 +25,15 @@ export interface Palette {
|
||||
green: string;
|
||||
red: string;
|
||||
yellow: string;
|
||||
/** ink `status.warningDim` / `status.errorDim` — the approval-mode chrome. */
|
||||
warningDim: string;
|
||||
errorDim: string;
|
||||
purple: string;
|
||||
/** ink `ui.symbol` — the shell-mode status color. */
|
||||
symbol: string;
|
||||
/** ink `border.focused` / `border.default` — the composer outline. */
|
||||
borderFocused: string;
|
||||
borderDefault: string;
|
||||
hover: string;
|
||||
/** Mode background; lets selection colors keep contrast on light themes. */
|
||||
bg?: string;
|
||||
@@ -42,7 +50,12 @@ const DARK: Palette = {
|
||||
green: '#A6E3A1',
|
||||
red: '#F38BA8',
|
||||
yellow: '#F9E2AF',
|
||||
warningDim: '#8B7530',
|
||||
errorDim: '#8B3A4A',
|
||||
purple: '#89B4FA',
|
||||
symbol: '#89DCEB',
|
||||
borderFocused: '#89B4FA',
|
||||
borderDefault: '#6C7086',
|
||||
hover: '#313244',
|
||||
// No bg on dark: the default invert selection (bg=text fg, fg=black) is
|
||||
// readable on dark terminals, and leaving bg unset keeps transparency.
|
||||
@@ -57,7 +70,12 @@ const LIGHT: Palette = {
|
||||
green: '#3CA84B',
|
||||
red: '#DD4C4C',
|
||||
yellow: '#D5A40A',
|
||||
warningDim: '#8B7000',
|
||||
errorDim: '#993333',
|
||||
purple: '#3B82F6',
|
||||
symbol: '#06B6D4',
|
||||
borderFocused: '#3B82F6',
|
||||
borderDefault: '#97a0b0',
|
||||
hover: '#E6E9EF',
|
||||
// Light: paint the markdown block with the original light theme's
|
||||
// Background so opentui's invert-selection (fg→bg swap) stays readable —
|
||||
@@ -71,7 +89,9 @@ const LIGHT: Palette = {
|
||||
* `applyThemeMode` mutates it and a React re-render picks it up. */
|
||||
export const C: Palette = { ...DARK };
|
||||
|
||||
function buildSyntax(mode: 'dark' | 'light'): SyntaxStyle {
|
||||
function buildSyntax(
|
||||
mode: 'dark' | 'light',
|
||||
): Record<string, StyleDefinitionInput> {
|
||||
const styles =
|
||||
mode === 'light'
|
||||
? {
|
||||
@@ -108,10 +128,10 @@ function buildSyntax(mode: 'dark' | 'light'): SyntaxStyle {
|
||||
link: { fg: '#7aa2f7' },
|
||||
code: { fg: '#9ece6a' },
|
||||
};
|
||||
return SyntaxStyle.fromStyles({
|
||||
return {
|
||||
...styles,
|
||||
...markdownMarkupTokens(mode),
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -150,19 +170,67 @@ export function markdownMarkupTokens(
|
||||
};
|
||||
}
|
||||
|
||||
/** Mutable syntax style — rebuilt on theme change. */
|
||||
export let SYNTAX: SyntaxStyle = buildSyntax('dark');
|
||||
/** Mutable syntax styles — rebuilt on theme change. */
|
||||
export let SYNTAX: SyntaxStyle;
|
||||
|
||||
/**
|
||||
* The thought-body variant. ink renders it through MarkdownDisplay with
|
||||
* `textColor={theme.text.secondary}`, which paints its plain text *and* its
|
||||
* headings — only inline code and links keep their own colors. OpenTUI's
|
||||
* `default` token colors plain inline text and wins over the element's `fg`
|
||||
* prop, so the dimmed body needs its own token map. A theme that omits
|
||||
* `default` (NoColor) keeps it omitted, as ink leaves that text uncolored.
|
||||
*/
|
||||
export let SYNTAX_DIM: SyntaxStyle;
|
||||
|
||||
function dimThoughtTokens(
|
||||
styles: Record<string, StyleDefinitionInput>,
|
||||
): Record<string, StyleDefinitionInput> {
|
||||
const dimmed = { ...styles };
|
||||
for (const token of Object.keys(dimmed)) {
|
||||
if (token === 'default' || token.startsWith('markup.heading')) {
|
||||
dimmed[token] = { ...dimmed[token], fg: C.dim };
|
||||
}
|
||||
}
|
||||
return dimmed;
|
||||
}
|
||||
|
||||
function setSyntax(styles: Record<string, StyleDefinitionInput>): void {
|
||||
SYNTAX = SyntaxStyle.fromStyles(styles);
|
||||
SYNTAX_DIM = SyntaxStyle.fromStyles(dimThoughtTokens(styles));
|
||||
}
|
||||
|
||||
setSyntax(buildSyntax('dark'));
|
||||
|
||||
/**
|
||||
* The banner wordmark ramp — ink's `ui.gradient` for the active theme. Empty
|
||||
* means the logo renders uncolored, which is what ink does when a theme has
|
||||
* no two usable stops. Seeded with the built-in themes' `GradientColors` so
|
||||
* the first paint, before any theme is applied, already matches ink.
|
||||
*/
|
||||
const DEFAULT_GRADIENT = ['#4796E4', '#847ACE', '#C3677F'];
|
||||
|
||||
export let GRADIENT: string[] = DEFAULT_GRADIENT;
|
||||
|
||||
/**
|
||||
* Bumped on every repaint of the mutable palette. `C` is mutated in place and
|
||||
* neither `settings` nor `config` changes identity when `/theme` writes, so a
|
||||
* colour-bearing `useMemo` needs this as a dependency to recompute.
|
||||
*/
|
||||
export let THEME_REVISION = 0;
|
||||
|
||||
export function applyThemeMode(
|
||||
mode: 'dark' | 'light' | null | undefined,
|
||||
): void {
|
||||
THEME_REVISION++;
|
||||
const m = mode === 'light' ? 'light' : 'dark';
|
||||
const surface = m === 'light' ? LIGHT : DARK;
|
||||
Object.assign(C, surface);
|
||||
// The dark surface has no `bg` (terminal transparency); Object.assign
|
||||
// never deletes keys, so a previous light `bg` must be cleared explicitly.
|
||||
C.bg = surface.bg;
|
||||
SYNTAX = buildSyntax(m);
|
||||
GRADIENT = DEFAULT_GRADIENT;
|
||||
setSyntax(buildSyntax(m));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -178,6 +246,7 @@ export function applyThemeMode(
|
||||
* to magenta. Unresolvable values stay unset (ink degrades similarly).
|
||||
*/
|
||||
export function applyOpenTuiTheme(definition: OpenTuiThemeDefinition): void {
|
||||
THEME_REVISION++;
|
||||
const light = definition.type === 'light';
|
||||
const surface = light ? LIGHT : DARK;
|
||||
// Empty-string palette values mean "no color" in ink themes (the NoColor
|
||||
@@ -191,6 +260,13 @@ export function applyOpenTuiTheme(definition: OpenTuiThemeDefinition): void {
|
||||
// The dark surface intentionally has no `bg` (keeps terminal transparency);
|
||||
// Object.assign never clears keys, so reset it explicitly.
|
||||
C.bg = surface.bg;
|
||||
// Same parseColor reason as the palette: resolve the stops to #rrggbb.
|
||||
// Fewer than two survivors means no ramp at all — ink renders the logo
|
||||
// uncolored in that case rather than painting a single stop.
|
||||
const gradient = definition.gradient
|
||||
.map((stop) => toHex(stop))
|
||||
.filter((stop): stop is string => stop !== undefined);
|
||||
GRADIENT = gradient.length >= 2 ? gradient : [];
|
||||
const syntaxStyles: Record<string, StyleDefinitionInput> = {};
|
||||
for (const [token, style] of Object.entries(definition.syntaxStyles)) {
|
||||
const resolved: StyleDefinitionInput = { ...style };
|
||||
@@ -204,7 +280,7 @@ export function applyOpenTuiTheme(definition: OpenTuiThemeDefinition): void {
|
||||
}
|
||||
syntaxStyles[token] = resolved;
|
||||
}
|
||||
SYNTAX = SyntaxStyle.fromStyles({
|
||||
setSyntax({
|
||||
// `default` colors unstyled markdown chunks (table cells, plain inline
|
||||
// text); anchor it on the theme's own foreground when it resolved.
|
||||
...(palette.text ? { default: { fg: palette.text } } : {}),
|
||||
|
||||
@@ -145,3 +145,56 @@ describe('transcriptToEvents resumed tool calls', () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('transcriptToEvents assistant timestamps (#76)', () => {
|
||||
const RECORDED = '2026-09-18T07:05:09.000Z';
|
||||
|
||||
function assistantLine(
|
||||
parts: Array<Record<string, unknown>>,
|
||||
extra: Record<string, unknown> = {},
|
||||
): string {
|
||||
return JSON.stringify({
|
||||
type: 'assistant',
|
||||
message: { role: 'assistant', parts },
|
||||
...extra,
|
||||
});
|
||||
}
|
||||
|
||||
function replay(line: string) {
|
||||
return transcriptToEvents(
|
||||
[line, JSON.stringify({ type: 'done' })].join('\n'),
|
||||
);
|
||||
}
|
||||
|
||||
it('stamps the first text run of the record, and only that one', () => {
|
||||
// A leading thought must not consume the stamp: ink puts the clock label
|
||||
// above the assistant message, not above the reasoning block.
|
||||
const events = replay(
|
||||
assistantLine(
|
||||
[
|
||||
{ thought: true, text: 'READING' },
|
||||
{ text: 'FIRST' },
|
||||
{ text: 'SECOND' },
|
||||
],
|
||||
{ timestamp: RECORDED },
|
||||
),
|
||||
);
|
||||
expect(events).toEqual([
|
||||
{ type: 'thinking', delta: 'READING' },
|
||||
{ type: 'thinking-end' },
|
||||
{ type: 'text', delta: 'FIRST', timestamp: Date.parse(RECORDED) },
|
||||
{ type: 'text', delta: 'SECOND' },
|
||||
{ type: 'done' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('leaves text events unstamped when the record has no usable time', () => {
|
||||
expect(replay(assistantLine([{ text: 'FIRST' }]))).toEqual([
|
||||
{ type: 'text', delta: 'FIRST' },
|
||||
{ type: 'done' },
|
||||
]);
|
||||
expect(
|
||||
replay(assistantLine([{ text: 'FIRST' }], { timestamp: 'not-a-date' })),
|
||||
).toEqual([{ type: 'text', delta: 'FIRST' }, { type: 'done' }]);
|
||||
});
|
||||
});
|
||||
@@ -30,6 +30,8 @@ interface SessionPart {
|
||||
}
|
||||
interface SessionLine {
|
||||
type?: string;
|
||||
/** ISO 8601 record time; ink's resume stamps it on the first display run. */
|
||||
timestamp?: string;
|
||||
message?: { role?: string; parts?: SessionPart[] };
|
||||
}
|
||||
|
||||
@@ -152,6 +154,8 @@ export function transcribeSession(
|
||||
}
|
||||
if (o.type === 'assistant') {
|
||||
let thinkingOpen = false;
|
||||
const recorded = o.timestamp ? Date.parse(o.timestamp) : NaN;
|
||||
let stamped = false;
|
||||
for (const p of parts) {
|
||||
if (p.thought && p.text) {
|
||||
events.push({ type: 'thinking', delta: p.text });
|
||||
@@ -179,7 +183,12 @@ export function transcribeSession(
|
||||
);
|
||||
if (args) events.push({ type: 'tool-args', id, args });
|
||||
} else if (p.text) {
|
||||
events.push({ type: 'text', delta: p.text });
|
||||
const stamp =
|
||||
!stamped && Number.isFinite(recorded)
|
||||
? { timestamp: recorded }
|
||||
: {};
|
||||
stamped = true;
|
||||
events.push({ type: 'text', delta: p.text, ...stamp });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,10 +37,23 @@ const mocks = vi.hoisted(() => {
|
||||
) => {
|
||||
const config = key === undefined ? props : { ...props, key };
|
||||
const children = (config?.children ?? null) as React.ReactNode;
|
||||
if (type === 'box' || type === 'text') {
|
||||
if (type === 'box' || type === 'text' || type === 'span') {
|
||||
// jsdom drops unknown props, so the colour-bearing opentui attributes
|
||||
// are surfaced as data-* for assertions to pin.
|
||||
const dom: Record<string, unknown> = key === undefined ? {} : { key };
|
||||
const source = (config ?? {}) as Record<string, unknown>;
|
||||
for (const name of ['fg', 'bg', 'borderColor', 'attributes']) {
|
||||
if (source[name] !== undefined) dom[`data-${name}`] = source[name];
|
||||
}
|
||||
if (source['flexDirection'] !== undefined) {
|
||||
dom['data-direction'] = source['flexDirection'];
|
||||
}
|
||||
if (source['width'] !== undefined) {
|
||||
dom['data-width'] = String(source['width']);
|
||||
}
|
||||
return React.createElement(
|
||||
type === 'box' ? 'div' : 'span',
|
||||
key === undefined ? null : { key },
|
||||
dom,
|
||||
children,
|
||||
);
|
||||
}
|
||||
@@ -59,7 +72,12 @@ vi.mock('@opentui/react/jsx-runtime', () => mocks.buildJsxRuntime());
|
||||
vi.mock('@opentui/react/jsx-dev-runtime', () => mocks.buildJsxRuntime());
|
||||
|
||||
import { OpenTuiTranscriptView } from './transcript-view.js';
|
||||
import type { LiveThinkingItem, LiveToolItem } from './live-session-model.js';
|
||||
import { C } from './theme.js';
|
||||
import type {
|
||||
LiveAssistantItem,
|
||||
LiveThinkingItem,
|
||||
LiveToolItem,
|
||||
} from './live-session-model.js';
|
||||
|
||||
const toolItem = (overrides: Partial<LiveToolItem> = {}): LiveToolItem => ({
|
||||
kind: 'tool',
|
||||
@@ -407,6 +425,53 @@ describe('OpenTuiTranscriptView', () => {
|
||||
expect(container.textContent).toContain('echo done');
|
||||
});
|
||||
|
||||
it('bolds only the tool name, leaving the status glyph at ink’s weight', () => {
|
||||
const { container } = render(
|
||||
<OpenTuiTranscriptView items={[toolItem({ tool: 'custom-tool' })]} />,
|
||||
);
|
||||
const name = [...container.querySelectorAll('span')]
|
||||
.filter((row) => (row.textContent ?? '').includes('custom-tool'))
|
||||
.pop();
|
||||
expect(name).toBeDefined();
|
||||
const glyph = name
|
||||
?.closest('div[data-direction="row"]')
|
||||
?.querySelector('div > span');
|
||||
expect(glyph?.textContent?.trim()).not.toBe('');
|
||||
expect(glyph?.getAttribute('data-attributes')).toBe('0');
|
||||
expect(name?.getAttribute('data-attributes')).toBe('1');
|
||||
});
|
||||
|
||||
it('paints the subagent summary as ink’s three runs', () => {
|
||||
const { container } = render(
|
||||
<OpenTuiTranscriptView
|
||||
items={[
|
||||
toolItem({
|
||||
output: '',
|
||||
subagentSummary: {
|
||||
glyph: '✔',
|
||||
tone: 'success',
|
||||
prefix: 'reviewer: ',
|
||||
rest: 'check imports · 5 tools',
|
||||
},
|
||||
}),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
const spans = [...container.querySelectorAll('span')];
|
||||
const glyph = spans.find((row) => (row.textContent ?? '').trim() === '✔');
|
||||
expect(glyph?.getAttribute('data-fg')).toBe(C.green);
|
||||
expect(glyph?.getAttribute('data-attributes')).toBeNull();
|
||||
const prefix = spans.find((row) =>
|
||||
(row.textContent ?? '').includes('reviewer:'),
|
||||
);
|
||||
expect(prefix?.getAttribute('data-fg')).toBe(C.text);
|
||||
expect(prefix?.getAttribute('data-attributes')).toBe('1');
|
||||
const rest = spans.find((row) =>
|
||||
(row.textContent ?? '').includes('check imports'),
|
||||
);
|
||||
expect(rest?.getAttribute('data-fg')).toBe(C.dim);
|
||||
});
|
||||
|
||||
it('renders the ! shell row with the ink $ prefix', () => {
|
||||
const { container } = render(
|
||||
<OpenTuiTranscriptView
|
||||
@@ -478,17 +543,221 @@ describe('OpenTuiTranscriptView', () => {
|
||||
];
|
||||
const collapsed = render(<OpenTuiTranscriptView items={items} />);
|
||||
expect(collapsed.container.textContent).toContain('Thought for 12s');
|
||||
expect(collapsed.container.textContent).not.toContain(
|
||||
'INSPECTING_THE_REPOSITORY',
|
||||
);
|
||||
expect(collapsed.container.querySelector('markdown')).toBeNull();
|
||||
collapsed.unmount();
|
||||
|
||||
const expanded = render(
|
||||
<OpenTuiTranscriptView items={items} thoughtsExpanded />,
|
||||
);
|
||||
expect(expanded.container.textContent).toContain(
|
||||
'INSPECTING_THE_REPOSITORY',
|
||||
);
|
||||
expect(
|
||||
expanded.container.querySelector('markdown')?.getAttribute('content'),
|
||||
).toContain('INSPECTING_THE_REPOSITORY');
|
||||
expect(expanded.container.textContent).toContain('ctrl+o to collapse');
|
||||
});
|
||||
|
||||
it('drops the click affordance from both hints when ui.mouseTracking is off (#172)', () => {
|
||||
const thought: LiveThinkingItem[] = [
|
||||
{
|
||||
kind: 'thinking',
|
||||
id: 'th1',
|
||||
text: 'BODY',
|
||||
done: true,
|
||||
durationMs: 12_000,
|
||||
},
|
||||
];
|
||||
const tool = [toolItem({ description: 'echo HI', done: true })];
|
||||
|
||||
const clickable = render(<OpenTuiTranscriptView items={thought} />);
|
||||
expect(clickable.container.textContent).toContain('click or ctrl+o');
|
||||
clickable.unmount();
|
||||
const toolClickable = render(
|
||||
<OpenTuiTranscriptView items={tool} showToolCallDetails={false} />,
|
||||
);
|
||||
expect(toolClickable.container.textContent).toContain('click to expand');
|
||||
toolClickable.unmount();
|
||||
|
||||
// The renderer takes no pointer events in this configuration, so a hint
|
||||
// offering a click would advertise a dead affordance.
|
||||
const noMouse = render(
|
||||
<OpenTuiTranscriptView items={thought} mouseTracking={false} />,
|
||||
);
|
||||
expect(noMouse.container.textContent).toContain('(ctrl+o to expand)');
|
||||
expect(noMouse.container.textContent).not.toContain('click');
|
||||
noMouse.unmount();
|
||||
const toolNoMouse = render(
|
||||
<OpenTuiTranscriptView
|
||||
items={tool}
|
||||
showToolCallDetails={false}
|
||||
mouseTracking={false}
|
||||
/>,
|
||||
);
|
||||
expect(toolNoMouse.container.textContent).toContain('ctrl+o to expand');
|
||||
expect(toolNoMouse.container.textContent).not.toContain('click');
|
||||
});
|
||||
|
||||
it('stamps the assistant row only when output.showTimestamps is on (#76)', () => {
|
||||
const items: LiveAssistantItem[] = [
|
||||
{
|
||||
kind: 'assistant',
|
||||
id: 'a1',
|
||||
text: 'ANSWER_BODY',
|
||||
streaming: false,
|
||||
timestamp: Date.UTC(2026, 8, 18, 7, 5, 9),
|
||||
},
|
||||
];
|
||||
const stamped = render(
|
||||
<OpenTuiTranscriptView items={items} showTimestamps />,
|
||||
);
|
||||
expect(stamped.container.textContent).toMatch(/\[\d{2}:\d{2}:\d{2}\]/);
|
||||
stamped.unmount();
|
||||
|
||||
const plain = render(<OpenTuiTranscriptView items={items} />);
|
||||
expect(plain.container.textContent).not.toMatch(/\[\d{2}:\d{2}:\d{2}\]/);
|
||||
});
|
||||
|
||||
it('remounts the assistant row when the stamp setting flips (#170)', () => {
|
||||
// `output.showTimestamps` is dialog-settable and needs no restart, so an
|
||||
// already-mounted row switches shape mid-session. The two shapes differ,
|
||||
// and this renderer never clears a prop a re-render drops, so they have to
|
||||
// remount rather than reuse: the stamped branch's second child would
|
||||
// otherwise keep the flexGrow the unstamped one gave it.
|
||||
const items: LiveAssistantItem[] = [
|
||||
{
|
||||
kind: 'assistant',
|
||||
id: 'a1',
|
||||
text: 'ANSWER_BODY',
|
||||
streaming: false,
|
||||
timestamp: Date.UTC(2026, 8, 18, 7, 5, 9),
|
||||
},
|
||||
];
|
||||
const { container, rerender } = render(
|
||||
<OpenTuiTranscriptView items={items} />,
|
||||
);
|
||||
const bare = container.querySelector('[data-direction="row"]');
|
||||
expect(bare?.querySelector('markdown')?.getAttribute('content')).toContain(
|
||||
'ANSWER_BODY',
|
||||
);
|
||||
|
||||
rerender(<OpenTuiTranscriptView items={items} showTimestamps />);
|
||||
expect(bare?.isConnected).toBe(false);
|
||||
expect(container.textContent).toMatch(/\[\d{2}:\d{2}:\d{2}\]/);
|
||||
expect(
|
||||
container.querySelector('markdown')?.getAttribute('content'),
|
||||
).toContain('ANSWER_BODY');
|
||||
});
|
||||
|
||||
it('gives the row glyph a structural gap, not a trailing space (#188)', () => {
|
||||
// A space inside the glyph's own text node is squeezed out as soon as the
|
||||
// sibling needs the full width, so a long wrapped answer printed as
|
||||
// `◆︎Answer`. Only a fixed-width box keeps the column.
|
||||
const rows = render(
|
||||
<OpenTuiTranscriptView
|
||||
items={[
|
||||
{ kind: 'user', id: 'u1', text: 'QUESTION_BODY' },
|
||||
{
|
||||
kind: 'assistant',
|
||||
id: 'a1',
|
||||
text: 'ANSWER_BODY',
|
||||
streaming: false,
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
const glyphBoxes = rows.container.querySelectorAll('[data-width="2"]');
|
||||
expect(glyphBoxes).toHaveLength(2);
|
||||
for (const box of glyphBoxes) {
|
||||
const glyph = box.firstElementChild;
|
||||
expect(glyph?.textContent).toMatch(/^\S{1,2}$/u);
|
||||
expect(glyph?.parentElement).toBe(box);
|
||||
}
|
||||
expect(rows.container.textContent).toContain('QUESTION_BODY');
|
||||
expect(
|
||||
rows.container.querySelector('markdown')?.getAttribute('content'),
|
||||
).toContain('ANSWER_BODY');
|
||||
});
|
||||
|
||||
it('collapses a settled tool card when ui.showToolCallDetails is false (#85)', () => {
|
||||
const items = [
|
||||
toolItem({
|
||||
tool: 'mcp__fs__write_file',
|
||||
description: '{"path":"/x","content":"SECRET_PAYLOAD"}',
|
||||
done: true,
|
||||
}),
|
||||
];
|
||||
const collapsed = render(
|
||||
<OpenTuiTranscriptView items={items} showToolCallDetails={false} />,
|
||||
);
|
||||
expect(collapsed.container.textContent).not.toContain('SECRET_PAYLOAD');
|
||||
expect(collapsed.container.textContent).toContain('click to expand');
|
||||
collapsed.unmount();
|
||||
|
||||
const detailed = render(<OpenTuiTranscriptView items={items} />);
|
||||
expect(detailed.container.textContent).toContain('SECRET_PAYLOAD');
|
||||
expect(detailed.container.textContent).not.toContain('click to expand');
|
||||
});
|
||||
|
||||
it('keeps the header row element when ctrl+O opens a collapsed card (#167)', () => {
|
||||
// @opentui/react never clears a prop a re-render drops, so a collapsed
|
||||
// branch of its own shape reconciled its glyph box onto the expanded
|
||||
// header row and left width=2 stuck on it: the terminal drew `✓ S` and
|
||||
// lost the description. One tree shape for both states is the fix, and
|
||||
// element identity across the toggle is what pins it.
|
||||
const items = [toolItem({ description: 'echo ACCEPT_MARKER', done: true })];
|
||||
const { container, rerender } = render(
|
||||
<OpenTuiTranscriptView
|
||||
items={items}
|
||||
showToolCallDetails={false}
|
||||
thoughtsExpanded={false}
|
||||
/>,
|
||||
);
|
||||
const headerOf = (label: string) => {
|
||||
const span = [...container.querySelectorAll('span')].find((el) =>
|
||||
(el.textContent ?? '').includes(label),
|
||||
);
|
||||
let node = span?.parentElement ?? null;
|
||||
while (node && node.dataset['direction'] !== 'row') {
|
||||
node = node.parentElement;
|
||||
}
|
||||
return node;
|
||||
};
|
||||
const collapsedHeader = headerOf('Shell');
|
||||
expect(collapsedHeader?.dataset['direction']).toBe('row');
|
||||
expect(collapsedHeader?.dataset['width']).toBeUndefined();
|
||||
expect(collapsedHeader?.textContent).toContain('click to expand');
|
||||
|
||||
rerender(
|
||||
<OpenTuiTranscriptView
|
||||
items={items}
|
||||
showToolCallDetails={false}
|
||||
thoughtsExpanded
|
||||
/>,
|
||||
);
|
||||
const expandedHeader = headerOf('Shell');
|
||||
expect(expandedHeader).toBe(collapsedHeader);
|
||||
expect(expandedHeader?.dataset['width']).toBeUndefined();
|
||||
expect(expandedHeader?.textContent).toContain('echo ACCEPT_MARKER');
|
||||
expect(expandedHeader?.textContent).not.toContain('click to expand');
|
||||
// The glyph box is the only child that ever carries a width.
|
||||
expect(expandedHeader?.querySelectorAll('[data-width]')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('keeps a card that still needs an answer open with details off (#85)', () => {
|
||||
// ink's CollapsibleToolGroupMessage never collapses a call while it is
|
||||
// waiting: the card is the only surface carrying the payload being approved.
|
||||
const { container } = render(
|
||||
<OpenTuiTranscriptView
|
||||
awaitingCallId="t1"
|
||||
showToolCallDetails={false}
|
||||
items={[
|
||||
toolItem({
|
||||
tool: 'mcp__fs__write_file',
|
||||
description: '{"path":"/x","content":"SECRET_PAYLOAD"}',
|
||||
confirm: 'pending',
|
||||
}),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
expect(container.textContent).toContain('SECRET_PAYLOAD');
|
||||
expect(container.textContent).not.toContain('click to expand');
|
||||
});
|
||||
});
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { AgentStatus } from '@qwen-code/qwen-code-core';
|
||||
import { C, SYNTAX } from './theme.js';
|
||||
import { C, SYNTAX, SYNTAX_DIM, type Palette } from './theme.js';
|
||||
import {
|
||||
AnsiRows,
|
||||
TOOL_CARD_DESCRIPTION_ROWS,
|
||||
@@ -60,17 +60,30 @@ import {
|
||||
} from '../utils/textUtils.js';
|
||||
import { getCompressionStatusText } from '../utils/compression-text.js';
|
||||
import { ICON } from '../constants.js';
|
||||
import { formatDuration } from '../utils/formatters.js';
|
||||
import { formatClockTime, formatDuration } from '../utils/formatters.js';
|
||||
import { getArenaStatusLabel } from '../utils/displayUtils.js';
|
||||
import type { ArenaAgentCardData } from '../types.js';
|
||||
|
||||
const GOAL_COLOR: Record<GoalCardColor, string> = {
|
||||
secondary: C.dim,
|
||||
accent: C.accent,
|
||||
warning: C.yellow,
|
||||
error: C.red,
|
||||
success: C.green,
|
||||
};
|
||||
/** ink HistoryItemDisplay picks between two hints by whether the card is
|
||||
* clickable; `ui.mouseTracking: false` hands the pointer back to the
|
||||
* terminal, so only the key hint is offered. */
|
||||
function expandHint(clickable: boolean): string {
|
||||
return clickable ? 'click to expand' : 'ctrl+o to expand';
|
||||
}
|
||||
|
||||
/** Keys, not colours: `C` is mutated in place when the theme is applied,
|
||||
* which happens after this module is imported. */
|
||||
const GOAL_COLOR_KEY = {
|
||||
accent: 'accent',
|
||||
warning: 'yellow',
|
||||
error: 'red',
|
||||
success: 'green',
|
||||
secondary: 'dim',
|
||||
} as const satisfies Record<GoalCardColor, keyof Palette>;
|
||||
|
||||
function goalColor(color: GoalCardColor): string {
|
||||
return C[GOAL_COLOR_KEY[color]];
|
||||
}
|
||||
|
||||
export interface TranscriptViewProps {
|
||||
items: readonly LiveHistoryItem[];
|
||||
@@ -83,6 +96,14 @@ export interface TranscriptViewProps {
|
||||
/** `ui.showToolCallArgs`: ink draws each call's raw arguments on their own
|
||||
* line under the card header. */
|
||||
showToolCallArgs?: boolean;
|
||||
/** `output.showTimestamps`: ink stamps `[HH:MM:SS]` above each assistant row. */
|
||||
showTimestamps?: boolean;
|
||||
/** `ui.showToolCallDetails`: false collapses every tool card to its header
|
||||
* plus an expand hint, the way ink's CollapsibleToolGroupMessage does. */
|
||||
showToolCallDetails?: boolean;
|
||||
/** `ui.mouseTracking`: false means the renderer takes no pointer events, so
|
||||
* the expand hints must not offer a click. */
|
||||
mouseTracking?: boolean;
|
||||
/** The call whose confirmation is on screen. ink's trailing marker points at
|
||||
* the call the user can answer, and only the waiting queue knows which that
|
||||
* is: a PreToolUse `ask` hook re-arms an already approved call by appending it
|
||||
@@ -116,6 +137,9 @@ export function OpenTuiTranscriptView({
|
||||
availableTerminalHeight = 24,
|
||||
thoughtsExpanded = false,
|
||||
showToolCallArgs = false,
|
||||
showTimestamps = false,
|
||||
showToolCallDetails = true,
|
||||
mouseTracking = true,
|
||||
awaitingCallId,
|
||||
}: TranscriptViewProps) {
|
||||
const maxRows = maxHistoryItemRows(availableTerminalHeight);
|
||||
@@ -141,6 +165,9 @@ export function OpenTuiTranscriptView({
|
||||
width={availableWidth}
|
||||
thoughtsExpanded={thoughtsExpanded}
|
||||
showToolCallArgs={showToolCallArgs}
|
||||
showTimestamps={showTimestamps}
|
||||
showToolCallDetails={showToolCallDetails}
|
||||
mouseTracking={mouseTracking}
|
||||
awaitingApproval={item.id === awaitingId}
|
||||
/>
|
||||
</box>
|
||||
@@ -156,6 +183,9 @@ function TranscriptItem({
|
||||
width,
|
||||
thoughtsExpanded,
|
||||
showToolCallArgs,
|
||||
showTimestamps,
|
||||
showToolCallDetails,
|
||||
mouseTracking,
|
||||
awaitingApproval,
|
||||
}: {
|
||||
item: LiveHistoryItem;
|
||||
@@ -164,15 +194,30 @@ function TranscriptItem({
|
||||
width: number;
|
||||
thoughtsExpanded: boolean;
|
||||
showToolCallArgs: boolean;
|
||||
showTimestamps: boolean;
|
||||
showToolCallDetails: boolean;
|
||||
mouseTracking: boolean;
|
||||
awaitingApproval: boolean;
|
||||
}) {
|
||||
switch (item.kind) {
|
||||
case 'user':
|
||||
return <UserRow text={item.text} />;
|
||||
case 'assistant':
|
||||
return <AssistantRow text={item.text} streaming={item.streaming} />;
|
||||
return (
|
||||
<AssistantRow
|
||||
text={item.text}
|
||||
streaming={item.streaming}
|
||||
timestamp={showTimestamps ? item.timestamp : undefined}
|
||||
/>
|
||||
);
|
||||
case 'thinking':
|
||||
return <ThinkingRow item={item} allExpanded={thoughtsExpanded} />;
|
||||
return (
|
||||
<ThinkingRow
|
||||
item={item}
|
||||
allExpanded={thoughtsExpanded}
|
||||
mouseTracking={mouseTracking}
|
||||
/>
|
||||
);
|
||||
case 'tool':
|
||||
return (
|
||||
<ToolCard
|
||||
@@ -182,6 +227,8 @@ function TranscriptItem({
|
||||
width={width}
|
||||
fullDetail={thoughtsExpanded}
|
||||
showToolCallArgs={showToolCallArgs}
|
||||
showToolCallDetails={showToolCallDetails}
|
||||
mouseTracking={mouseTracking}
|
||||
awaitingApproval={awaitingApproval}
|
||||
/>
|
||||
);
|
||||
@@ -252,7 +299,11 @@ function UserRow({ text }: { text: string }) {
|
||||
const meta = userMessageMeta();
|
||||
return (
|
||||
<box flexDirection="row">
|
||||
<text fg={meta.color}>{`${meta.glyph} `}</text>
|
||||
{/* A trailing space in the glyph's own text node is squeezed out as soon
|
||||
as the sibling needs the full width, so the gap is structural. */}
|
||||
<box width={STATUS_INDICATOR_WIDTH}>
|
||||
<text fg={meta.color}>{meta.glyph}</text>
|
||||
</box>
|
||||
<text fg={meta.color} {...selectionProps()}>
|
||||
{sanitizeTerminalText(text)}
|
||||
</text>
|
||||
@@ -263,17 +314,24 @@ function UserRow({ text }: { text: string }) {
|
||||
function AssistantRow({
|
||||
text,
|
||||
streaming,
|
||||
timestamp,
|
||||
}: {
|
||||
text: string;
|
||||
streaming: boolean;
|
||||
timestamp?: number;
|
||||
}) {
|
||||
const meta = assistantMessageMeta();
|
||||
const content = sanitizeTerminalText(
|
||||
assistantMarkdownForRender(text, streaming),
|
||||
);
|
||||
return (
|
||||
<box flexDirection="row">
|
||||
<text fg={meta.color}>{`${meta.glyph} `}</text>
|
||||
// The two shapes differ, and `output.showTimestamps` is a setting the dialog
|
||||
// can flip mid-session: without keys the stamped branch's second child would
|
||||
// reuse the unstamped one's and keep the flexGrow it was given (Decision 11).
|
||||
const row = (
|
||||
<box key="bare" flexDirection="row">
|
||||
<box width={STATUS_INDICATOR_WIDTH}>
|
||||
<text fg={meta.color}>{meta.glyph}</text>
|
||||
</box>
|
||||
<box flexGrow={1}>
|
||||
<markdown
|
||||
content={content}
|
||||
@@ -283,20 +341,35 @@ function AssistantRow({
|
||||
</box>
|
||||
</box>
|
||||
);
|
||||
if (timestamp === undefined) return row;
|
||||
return (
|
||||
<box key="stamped" flexDirection="column">
|
||||
<text fg={C.dim}>{formatClockTime(timestamp)}</text>
|
||||
{row}
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
function ThinkingRow({
|
||||
item,
|
||||
allExpanded,
|
||||
mouseTracking,
|
||||
}: {
|
||||
item: LiveThinkingItem;
|
||||
allExpanded: boolean;
|
||||
mouseTracking: boolean;
|
||||
}) {
|
||||
const [clickedOpen, setClickedOpen] = useState(false);
|
||||
// ink resolves a thought as the global ctrl+O toggle or its own clicked-open
|
||||
// head id, so switching the global back off leaves a hand-opened thought open.
|
||||
const expanded = allExpanded || clickedOpen;
|
||||
const meta = thinkingMeta(item.done, expanded, false, item.durationMs);
|
||||
// A live thought has no stamped duration yet; ink re-derives the elapsed
|
||||
// time on every render so the pending label carries it.
|
||||
const durationMs =
|
||||
item.durationMs ??
|
||||
(item.startedAt === undefined ? undefined : Date.now() - item.startedAt);
|
||||
const meta = thinkingMeta(item.done, expanded, durationMs, mouseTracking);
|
||||
const body = item.text.trimEnd();
|
||||
return (
|
||||
<box
|
||||
flexDirection="column"
|
||||
@@ -310,10 +383,18 @@ function ThinkingRow({
|
||||
{meta.hint ? ` ${meta.hint}` : ''}
|
||||
</text>
|
||||
</box>
|
||||
{!meta.collapsed && item.text ? (
|
||||
<text fg={C.dim} attributes={4} {...selectionProps()}>
|
||||
{sanitizeTerminalText(item.text)}
|
||||
</text>
|
||||
{!meta.collapsed && body ? (
|
||||
<box flexDirection="row">
|
||||
<box width={STATUS_INDICATOR_WIDTH} />
|
||||
<box flexGrow={1}>
|
||||
<markdown
|
||||
content={sanitizeTerminalText(body)}
|
||||
syntaxStyle={SYNTAX_DIM}
|
||||
streaming={!item.done}
|
||||
fg={C.dim}
|
||||
/>
|
||||
</box>
|
||||
</box>
|
||||
) : null}
|
||||
</box>
|
||||
);
|
||||
@@ -326,6 +407,8 @@ function ToolCard({
|
||||
width,
|
||||
fullDetail,
|
||||
showToolCallArgs,
|
||||
showToolCallDetails,
|
||||
mouseTracking,
|
||||
awaitingApproval,
|
||||
}: {
|
||||
item: LiveToolItem;
|
||||
@@ -334,10 +417,20 @@ function ToolCard({
|
||||
width: number;
|
||||
fullDetail: boolean;
|
||||
showToolCallArgs: boolean;
|
||||
showToolCallDetails: boolean;
|
||||
mouseTracking: boolean;
|
||||
awaitingApproval: boolean;
|
||||
}) {
|
||||
const [clickedOpen, setClickedOpen] = useState(false);
|
||||
const status = toolStatusMeta(item);
|
||||
const name = toolCardName(item.tool);
|
||||
// ink's CollapsibleToolGroupMessage keeps a call open while it needs an
|
||||
// answer: the card is the only surface carrying the payload being approved.
|
||||
const collapsed =
|
||||
!showToolCallDetails &&
|
||||
!clickedOpen &&
|
||||
!fullDetail &&
|
||||
item.confirm !== 'pending';
|
||||
const description =
|
||||
item.description ?? toolCardDescription(item.tool, item.args);
|
||||
// Measure on the same basis the render uses: a live description (e.g. a
|
||||
@@ -375,43 +468,76 @@ function ToolCard({
|
||||
innerWidth > 0 ? innerWidth : undefined,
|
||||
)
|
||||
: undefined;
|
||||
// Both states share one tree shape. @opentui/react never clears a prop that a
|
||||
// re-render drops, so a collapsed branch of its own shape would leave its
|
||||
// glyph box's width stuck on the reconciled header row and clip the title.
|
||||
return (
|
||||
<box flexDirection="column">
|
||||
<box flexDirection="row">
|
||||
<box
|
||||
flexDirection="row"
|
||||
onMouseUp={() => {
|
||||
if (collapsed) setClickedOpen(true);
|
||||
}}
|
||||
>
|
||||
<box width={STATUS_INDICATOR_WIDTH}>
|
||||
<text
|
||||
fg={status.color}
|
||||
attributes={(status.strikethrough ? 128 : 0) | 1}
|
||||
>
|
||||
<text fg={status.color} attributes={status.strikethrough ? 128 : 0}>
|
||||
{status.glyph}
|
||||
</text>
|
||||
</box>
|
||||
<text fg={C.text} attributes={status.strikethrough ? 129 : 1}>
|
||||
{name}
|
||||
</text>
|
||||
{cap.description ? (
|
||||
<text fg={C.dim} {...selectionProps()}>
|
||||
{` ${sanitizeTerminalText(cap.description)}`}
|
||||
{collapsed ? (
|
||||
<>
|
||||
<text
|
||||
key="name"
|
||||
fg={C.text}
|
||||
attributes={status.strikethrough ? 129 : 1}
|
||||
>
|
||||
{name}
|
||||
</text>
|
||||
<text
|
||||
key="hint"
|
||||
fg={C.dim}
|
||||
>{` · ${expandHint(mouseTracking)}`}</text>
|
||||
</>
|
||||
) : (
|
||||
// ink wraps the name and the description as one block, so the
|
||||
// continuation rows align under the name and the trailing arrow ends
|
||||
// up after the last character. Separate flex siblings cannot do
|
||||
// that: they also drop the description's leading space.
|
||||
<text key="header" flexGrow={1} {...selectionProps()}>
|
||||
<span fg={C.text} attributes={status.strikethrough ? 129 : 1}>
|
||||
{name}
|
||||
</span>
|
||||
{cap.description ? (
|
||||
<span fg={C.dim}>
|
||||
{` ${sanitizeTerminalText(cap.description)}`}
|
||||
</span>
|
||||
) : null}
|
||||
{suffix ? (
|
||||
<span fg={C.dim}>{sanitizeTerminalText(suffix)}</span>
|
||||
) : null}
|
||||
{awaitingApproval ? (
|
||||
// ink's TrailingIndicator: a primary-coloured arrow at the end of
|
||||
// the awaiting call's own row, not a row of its own.
|
||||
<span fg={C.text}>{' ←'}</span>
|
||||
) : null}
|
||||
</text>
|
||||
) : null}
|
||||
{suffix ? <text fg={C.dim}>{sanitizeTerminalText(suffix)}</text> : null}
|
||||
{awaitingApproval ? (
|
||||
// ink's TrailingIndicator: a primary-coloured arrow at the end of the
|
||||
// awaiting call's own row, not a row of its own.
|
||||
<text fg={C.text}>{' ←'}</text>
|
||||
) : null}
|
||||
)}
|
||||
</box>
|
||||
{cap.hiddenRows > 0 && (
|
||||
<text fg={C.dim}>{hiddenTailLinesLabel(cap.hiddenRows)}</text>
|
||||
{collapsed ? null : (
|
||||
<>
|
||||
{cap.hiddenRows > 0 && (
|
||||
<text fg={C.dim}>{hiddenTailLinesLabel(cap.hiddenRows)}</text>
|
||||
)}
|
||||
{argsRow ? (
|
||||
<box paddingLeft={STATUS_INDICATOR_WIDTH}>
|
||||
<text fg={C.dim} {...selectionProps()}>
|
||||
{argsRow}
|
||||
</text>
|
||||
</box>
|
||||
) : null}
|
||||
<ToolCardBody item={item} maxRows={maxRows} width={width} />
|
||||
</>
|
||||
)}
|
||||
{argsRow ? (
|
||||
<box paddingLeft={STATUS_INDICATOR_WIDTH}>
|
||||
<text fg={C.dim} {...selectionProps()}>
|
||||
{argsRow}
|
||||
</text>
|
||||
</box>
|
||||
) : null}
|
||||
<ToolCardBody item={item} maxRows={maxRows} width={width} />
|
||||
</box>
|
||||
);
|
||||
}
|
||||
@@ -444,6 +570,26 @@ function ToolCardBody({
|
||||
</box>
|
||||
);
|
||||
}
|
||||
if (item.subagentSummary) {
|
||||
const summary = item.subagentSummary;
|
||||
const toneColor =
|
||||
summary.tone === 'success'
|
||||
? C.green
|
||||
: summary.tone === 'error'
|
||||
? C.red
|
||||
: C.yellow;
|
||||
return (
|
||||
<box paddingLeft={STATUS_INDICATOR_WIDTH + 1} flexDirection="row">
|
||||
<text fg={toneColor}>{`${summary.glyph} `}</text>
|
||||
<text fg={C.text} attributes={1}>
|
||||
{sanitizeTerminalText(summary.prefix)}
|
||||
</text>
|
||||
<text fg={C.dim} {...selectionProps()}>
|
||||
{sanitizeTerminalText(truncateResultDisplayChars(summary.rest))}
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
if (item.diff) {
|
||||
const lines = renderDiffBody(item.diff.fileDiff);
|
||||
const window = tailWindow(lines, maxRows);
|
||||
@@ -493,8 +639,8 @@ function TaskCard({
|
||||
return (
|
||||
<box flexDirection="column">
|
||||
<box flexDirection="row">
|
||||
<text fg={item.done ? C.green : C.text} attributes={1}>
|
||||
{item.done ? TOOL_GLYPH_DONE : TOOL_GLYPH_RUNNING}
|
||||
<text fg={C.text} attributes={1}>
|
||||
{TOOL_GLYPH_RUNNING}
|
||||
</text>
|
||||
<text fg={C.text} attributes={1}>
|
||||
{` ${sanitizeTerminalText(item.name)}`}
|
||||
@@ -502,7 +648,6 @@ function TaskCard({
|
||||
{item.description ? (
|
||||
<text fg={C.dim}> {sanitizeTerminalText(item.description)}</text>
|
||||
) : null}
|
||||
{item.stats ? <text fg={C.dim}>{` · ${item.stats}`}</text> : null}
|
||||
</box>
|
||||
{item.progress.map((line, i) => (
|
||||
<text key={`${i}`} fg={C.dim} {...selectionProps()}>
|
||||
@@ -513,7 +658,6 @@ function TaskCard({
|
||||
);
|
||||
}
|
||||
|
||||
const TOOL_GLYPH_DONE = ICON.CHECK;
|
||||
const TOOL_GLYPH_RUNNING = ICON.CIRCLE_LEFT_HALF;
|
||||
|
||||
function CompactionRow({
|
||||
@@ -619,7 +763,7 @@ function GoalCard({
|
||||
if (view.state === 'cleared') {
|
||||
return <text fg={C.dim}>Goal cleared</text>;
|
||||
}
|
||||
const color = GOAL_COLOR[view.color];
|
||||
const color = goalColor(view.color);
|
||||
return (
|
||||
<box flexDirection="column">
|
||||
<box flexDirection="row">
|
||||
@@ -656,7 +800,7 @@ function LegacyGoalCard({ legacy }: { legacy: LiveGoalLegacyData }) {
|
||||
</box>
|
||||
);
|
||||
}
|
||||
const color = GOAL_COLOR[view.color];
|
||||
const color = goalColor(view.color);
|
||||
return (
|
||||
<box flexDirection="column">
|
||||
<box flexDirection="row">
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
ToolNames,
|
||||
ToolNamesMigration,
|
||||
} from '@qwen-code/qwen-code-core/tools/tool-names.js';
|
||||
|
||||
/**
|
||||
* Names that resolve to the agent tool: the canonical name plus whatever
|
||||
* legacy request aliases core's migration map declares (e.g. 'task').
|
||||
* Tool-usage stats key on the raw request name, so the scrollback
|
||||
* sub-agent count must accept all of them.
|
||||
*
|
||||
* Shared because both renderers derive the same sub-agent count from the
|
||||
* execution summary and must not drift apart on which aliases count.
|
||||
*/
|
||||
export const AGENT_TOOL_NAMES: ReadonlySet<string> = new Set([
|
||||
ToolNames.AGENT,
|
||||
...Object.entries(ToolNamesMigration)
|
||||
.filter(([, canonical]) => canonical === ToolNames.AGENT)
|
||||
.map(([legacy]) => legacy),
|
||||
]);
|
||||
@@ -7,6 +7,7 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
contextUsageLabel,
|
||||
formatClockTime,
|
||||
formatDuration,
|
||||
formatMemoryUsage,
|
||||
formatPercentageUsed,
|
||||
@@ -243,4 +244,35 @@ describe('formatters', () => {
|
||||
expect(contextUsageLabel(40)).toBe('% used');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatClockTime', () => {
|
||||
// The label is built from the host locale clock, so pin the zone: without
|
||||
// this the same instant renders differently on a contributor's machine and
|
||||
// on CI.
|
||||
const originalTz = process.env['TZ'];
|
||||
|
||||
beforeEach(() => {
|
||||
process.env['TZ'] = 'UTC';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalTz === undefined) delete process.env['TZ'];
|
||||
else process.env['TZ'] = originalTz;
|
||||
});
|
||||
|
||||
it('renders a zero-padded 24-hour [HH:MM:SS] label', () => {
|
||||
expect(formatClockTime(Date.UTC(2026, 8, 18, 7, 5, 9))).toBe(
|
||||
'[07:05:09]',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps 24-hour numbering instead of a 12-hour clock', () => {
|
||||
expect(formatClockTime(Date.UTC(2026, 8, 18, 23, 0, 0))).toBe(
|
||||
'[23:00:00]',
|
||||
);
|
||||
expect(formatClockTime(Date.UTC(2026, 8, 18, 0, 0, 0))).toBe(
|
||||
'[00:00:00]',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -50,6 +50,15 @@ export const formatRelativeTime = (timestamp: number): string => {
|
||||
return 'just now';
|
||||
};
|
||||
|
||||
/** 24-hour `[HH:MM:SS]` clock label for `output.showTimestamps`. */
|
||||
export const formatClockTime = (timestamp: number): string =>
|
||||
`[${new Date(timestamp).toLocaleTimeString('en-US', {
|
||||
hour12: false,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
})}]`;
|
||||
|
||||
export const formatTokenCount = (count: number): string => {
|
||||
if (count < 1000) {
|
||||
return `${count}`;
|
||||
|
||||
Reference in new issue
Block a user