feat(panel): edit a row's time and tag inline #21
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/inline-edit"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Right-click a row → Edit time and tag… turns that row into an editor in
place. Start time and duration on one line with confirm/cancel, tag on the line
under them. Only a field you actually change sends a command; an untouched
submit runs nothing and does not even refetch.
Three verbs, and deliberately not
modifytimew move @id <the row's own date>THH:MMtimew lengthen|shorten @id PT<n>Stimew retag @id <text>timew modify start|endis the obvious instrument and it is the wrong one. Itsets an absolute end, so the panel would have to compose one from a
time-of-day plus a date — and that is where timew surprises you. Verified
against 1.9.1:
A bare time is not a parse error to timew, it is a valid timestamp on today's
date.
movecarries the duration with it andlengthen/shortentake arelative delta, so no end timestamp is ever built and that class of bug
cannot be written here. All three verbs also take the id in the same position,
which is why
Timew.cmd.byStartneeded one trailing argument (${4:+"$4"})rather than a word before the id as well.
moveruns last. Every command resolves the interval by its start — thesame identity the context menu already uses — and
moveis the only one thatchanges that start. Sent first it would leave
retagand the resize looking upa start that no longer exists and reporting the row as gone.
What is validated here, and what is left to timew
Only the two things timew answers badly: an unparseable time or duration,
where it replies
Cannot modify interval @N where start is after endand namesthe wrong problem.
Everything timew reports accurately reaches the user verbatim through
Parse.toast—You cannot overlap intervals…andCannot lengthen open interval @1. So the running interval's start is editable and its durationis not, and timew is what says so. A refusal stops the chain and leaves the
editor open with what was typed, so an overlap is corrected rather than
retyped.
The baseline has two jobs, and they are not one job
This is where the review round landed, and the first version of this PR ran them
together.
Whether a field changed is decided against the text the field was seeded
with.
Fmt.spanfloors to minutes above a minute, so a 15m32s interval displays0:15; comparing the parsed display back would have shortened it by 32s on anunchanged submit, and on the active row — whose duration ticks every second —
an untouched submit would have sent a resize nobody asked for.
Parse.intervalcarries
secondsbesidedurationandbeginEditcaptures both, so anuntouched field sends nothing however the row moves underneath.
By how much the duration changed is decided against the row as it now
stands, which the first version got wrong. Nothing closes this editor but its
own
done(ok):runrefetches from start, stop,startTag,startRecent,continue, delete and undo, and none of them touches
state.editing. So theinterval underneath can change length while the editor is open — right-click the
running row, click Stop, ask for
1:00on a row that is now0:45— andthe frozen number sent
lengthen PT1800S, leaving the interval at 1:15 withnothing to toast, because timew had done exactly what it was told.
submitEditnow finds the live row by
base.startand hands that toTimew.cmd.edits;startanddateare the same value either way, so onlysecondsmoves, and arow that has gone falls back to the baseline, which is what the miss message is
already there for.
The editor also closes with the panel now.
onOpencleared the card'sdraftand not the editor's four fields or its baseline, and there is no
onCloseinthe plugin API to catch them anywhere else — so a reopened panel came back with
a row already in edit mode, holding text from a previous session and a baseline
of unbounded age.
Two more subtleties
A multi-tag row's tag is refused rather than mangled. timew quotes a tag
containing a space and leaves a bare one alone, so a two-tag set arrives as
admin "deep work"and the parser's whole-string unquote cannot take thosequotes. Handing that back to
retagdoes not flatten the set: 1.9.1 answersNote: '"admin \"deep work\""' is a new tagand the interval keeps a tag bythat name, in
timew tags, for good.beginEditrecognises the shape — a quotestill in a value the parser already unquoted once — and the tag is left out of
that row's submit. The other two fields still work.
Two lines, and it is measured rather than chosen. A
ui.inputreserves30 px of inset flat —
Style::spaceMd+kTextInnerInset, both sides, andnothing in
kInputreachessetHorizontalPadding. Three fields plus twobuttons on one 283 px row left the time fields 22 px of viewport and
08:00rendered as
08:. No plugin API measures text, so the suite could not see it —a screenshot caught it. The time fields are budgeted for six glyphs, not
five:
Fmt.span's hour field has no bound, so a hundred-hour interval seeds100:00, and timew holds those happily (lengthen @1 PT359996400Sruns theinterval out to 2038). 76 px each, which the top line's 283 absorbs with 67 px
to spare.
Verification
Driven with real pointer and keyboard events against a real database (backed up
first; my probe intervals removed, the six real ones untouched):
08:00 1:00 edit-probe→10:30 2:15 edit-probe-2, one confirm, exact4:00, timew's own message toastedThe
byStartshell program was also run directly against a scratch database:retagwithdeep workstores one tag with a space,movepreserves duration,lengthen PT600Sadds exactly ten minutes, the menu's verbs still work with no$4at all, and a hostile tag arrives byte-intact with$HOMEunexpanded andbackticks unexecuted.
Gates:
nix shell nixpkgs#luau -c luau test.luau→13 states, 1870 field comparisons,ok;noctalia plugins lint timew→0 errors, 0 warnings.What that 1870 does not cover, said plainly.
mainat2029e99prints thesame line byte for byte: the state matrix gained zero comparisons here, and it
cannot have — all 13
check()calls run before the editor section, sostate.editingis""andeditKeyis0in every one of them, andcomparenever reads a row's
isEditingor the four new model fields. The figure is nota gate for this feature and is no longer quoted as one.
What does cover it is the editor section driving the tree — 82
fire(callsagainst 13 on
main, 658 asserts against 479 — plus a new two-wayMODEL_FIELDScensus beside the existing state one, soViewmodel.buildgrowing a junk top-level field, or a junk field on every row or every bar, now
fails where it used to pass.
58 source mutations run this round; 3 survive, and all three are named. The
author's original 31 all still die, including the eleven both reviewers found
alive:
EDIT_H24→20 and a droppedheight,focus = trueon the editorfields, either
gapzeroed, confirm'svariantmatching cancel's,cancelEditleaving
editBasepopulated,submitEditwithout itsbase.startguard,runChainfiringdone(ok)after the poke,onSubmitas a no-op / ascancelEdit/ on one field of three, one key shared by all three inputs,movecomposing today's date when the start moves forward, and the two directions of
the baseline confusion (replacing
basewholesale, and measuring the delta offthe frozen
seconds).One mutation is retired rather than killed: "comparing against the live row
instead of the baseline" was on the original list, and half of it is now the
correct behaviour. The suite pins both halves separately — an untouched submit
on a row that grew underneath sends nothing, and a touched one measures its
delta off the live seconds.
The three survivors, with why each is left:
EDIT_TIME_W76 → 71 — 41 px of viewport is exactly the floor the suiteasserts; the remaining 5 px is the caret's, which is judgment and not a
measurement.
breakin the live-row lookup — two intervals cannot share a start(timew forbids overlap), so the last match is the first match.
onOpenalso resettingeditKeyto 0 — nothing observes it, because aclosed panel's tree is gone. The counter is left monotonic on purpose, so a
fresh editor cannot reuse a key an earlier one used.
Known limits, documented not worked around
retagcannot empty a tag set.at all (above). A set of bare tags —
admin focus— carries no quote torecognise and still flattens into one tag if you edit the field; leave it
alone and the set is untouched.
time_formatis, so on a 12-hourshell the row reads
5:48 PMabove a field reading17:48.23:50row, typing
00:10moves the interval 23h40m backwards within that day —verified on 1.9.1,
Moved @2 to 2026-09-01T00:10:00.kScrollexposes noscroll offset, so opening one on the last visible row pushes part of it below
the fold. Scroll down by hand.
timew has no transaction to group them with.
Right-click a row -> "Edit time and tag..." turns that row into an editor in place: start time and duration on one line with confirm/cancel, tag on the line under them. Only a field you actually change sends a command; an untouched submit runs nothing and does not refetch. Three verbs, and deliberately not `modify`: start timew move @id <the row's own date>THH:MM duration timew lengthen|shorten @id PT<n>S tag timew retag @id <text> `modify start|end` sets an absolute end, so the panel would have to build one from a time-of-day plus a date -- and that is where timew surprises you. Verified against 1.9.1: `timew modify end @3 15:30` on a row dated the day before moved its end to *today* and silently produced a 25-hour interval, because a bare time is a valid timestamp on today's date. `move` carries the duration with it and lengthen/shorten take a relative delta, so no end timestamp is ever composed and that bug cannot be written here. All three also take the id in the same position, so Timew.cmd.byStart needed one trailing argument (`${4:+"$4"}`) rather than a word before the id as well. `move` runs last: every command resolves the interval by its start, and `move` is the only one that changes it. Sent first it would leave retag and the resize looking up a start that no longer exists. Validation covers only what timew answers badly -- an unparseable time or duration, where it replies "start is after end" and names the wrong problem. Overlap and "Cannot lengthen open interval" are accurate, so they reach the user verbatim through Parse.toast; the running interval's start is editable and its duration is not, and timew is what says so. A refusal stops the chain and leaves the editor open with what was typed. The duration delta is measured against the interval's raw seconds, not the string on screen: Fmt.span floors to minutes, so a 15m32s row displays 0:15 and an unchanged submit would otherwise have shortened it by 32s. Two lines, not one, and measured rather than chosen: a ui.input reserves 30px of inset flat (Style::spaceMd + kTextInnerInset, both sides, no plugin prop reaches it), so three fields plus two buttons on one 283px row left the time fields 22px of viewport and "08:00" rendered as "08:". No plugin API measures text, so the suite could not see it -- a screenshot caught it. Verified live with real pointer and keyboard events: a full three-field edit landed exactly (08:00 1:00 -> 10:30 2:15, tag retagged, one confirm); an overlapping duration was refused with timew's own message and the editor kept the typed values; a malformed time toasted this plugin's message and sent nothing; cancel discarded. 31 source mutations, no survivors.Review — source half (
parse.luau,timew.luau,panel.luau,viewmodel.luau,en.json)REQUEST_CHANGES. One reachable silent wrong write, plus the state leak that
widens it. Everything else is advisory.
test.luauis the sibling reviewer's;I only read it.
Gates on the branch as shipped:
13 states, 1870 field comparisons,ok;0 errors, 0 warnings. Everything below was run against my ownTIMEWARRIORDB=/tmp/rs2-timewdband a/tmpcopy of the tree.The claims hold. Checked one at a time, not taken on trust.
The 25-hour bug is real and worse than the PR states. Today is 2026-09-04:
73.5 hours, silently. The interval's own date never enters it — the bare time
resolves on today, so the error grows with the row's age.
And the three verbs cannot write it.
movepreserves duration(
14:00–15:00→move @1 …T09:00→09:00–10:00);lengthen/shortentake arelative delta and build no timestamp at all. The one absolute value the panel
composes is
base.date .. "T" .. want.at(timew.luau:415), anddateis asubstring of
start— one regex, nested capture,parse.luau:161-162— sothe two cannot disagree and the day can only ever be the row's own. I looked
specifically for a wrong-day escape near midnight (23:30 start running into the
next day) and on an interval longer than 24 h: there is none.
want.athas beenthrough
Parse.timeOfDay, so it isHH:MMand carries no date of its own.Command order: proven necessary, and the shipped one is right. Real
byStartscript, real database, three intervals, editing the middle one.${4:+"$4"}behaves. Invoked as the host does —/bin/sh -c "$SCRIPT" sh <verb> <start> <missing> [<arg>], with atimewshim printing its argv:continue/delete)<continue> <@2>— 2 elementsarg=""<continue> <@2>— 2 elements, nothing addedde"ep $HOME \id` * work␊second line`<retag> <@2> <de"ep $HOME \id` * work␊second line>`*<retag> <@2> <*>— not globbedOne element, byte-intact,
$HOMEunexpanded, backticks unexecuted, no fieldsplitting. Identical under
dash, so the idiom is not a bash accident.runis behaviourally unchanged. For#cmds == 1: success →apply→pokeBar→refresh; failure →pokeBar→refreshwith noapply. Samecalls in the same order as the body it replaced, and
undoable ~= falsestilldefaults true and is still only set after a command that landed.
Layout arithmetic checks out, and the 30 px is exact.
Style::spaceMd = 12.0F(style.h:25) +kTextInnerInset = 3.0F(input.cpp:54), applied bothsides at
input.cpp:878-880—width - textInset - rightInset.LIST_W= 305− 8 − 14 = 283; line one spends 68+68+24+24 plus four 4 px gaps = 200, leaving
the spacer 83. Nothing on that line can clip.
Cited lines, spot-checked against the tree —
input.h:231ism_horizontalPadding = Style::spaceMd,input.cpp:54iskTextInnerInset,input.cpp:878-880is the three-line viewport computation,plugin_bindings.cpp:454-459isuiNodePath,plugin_panel.cpp:419-422is idthen context. All five exact. One is not; see A3.
Blocking
B1 —
editBasenever reconciles with the row, and Stop turns that into a wrong writepanel.luau:905·panel.luau:833·timew.luau:398The baseline is written once in
beginEditand cleared only by cancel or asuccessful submit. Nothing in between re-seeds it — and the editor survives
every
refresh()the panel issues, becauserun→pokeBar→refreshnevertouches
state.editing. The comment atpanel.luau:1288believes otherwise;see A4.
The reachable gesture is four steps on the row the PR itself calls the one most
likely to be edited:
Driven through the panel's own nodes:
The row is 2700 s, the user asked for 3600 s, the correct delta is
PT900S. Thepanel sends
PT1800Sand the interval becomes 1:15. No toast — timew didexactly what it was told.
lengthen/shortenon an open interval are refused by name ("Cannot lengthenopen interval @1", verified), which is what masks this while tracking. Stopping
lifts the refusal and leaves the stale number in place. Any refetch that changes
the edited row's duration without changing its start does the same; the active
row is simply the one that does it every second.
The fix keeps the baseline for the decision and moves only the arithmetic. The
baseline is what makes an untouched field send nothing — that must not change —
but the size of the delta belongs to the row as it now stands:
B2 —
onOpenresets the card's field and not the editor's threepanel.luau:1523draftis cleared here because a half-typed tag must not survive a close. Theeditor's four fields and its baseline are not, and there is no
onClosein theplugin API to catch them elsewhere. Reopening the panel:
A row comes back already in edit mode holding text from a previous session, with
a confirm button beside it and a baseline of unbounded age. Everything B1
describes applies, with no bound on the staleness.
Advisory
A1 — a multi-tag row seeds literal quote characters into
retagpanel.luau:817·parse.luau:180Parse.unquotestrips only a string that both starts and ends with". Atwo-tag interval where one tag has a space does neither:
That is what the tag field opens with and what
retagreceives if the usertouches it:
The README's limit says a multi-tag interval "flattens". It also permanently
gains a tag whose name contains escaped quotes, which is a different and worse
thing. Either say so, or refuse the
retagwhenrow.tagis not a single tag —Parse.unquote(row.tag) == row.tag and string.find(row.tag, '"', 1, true)is thetest, and it costs one branch in
beginEdit.A2 — on a 12-hour shell the field disagrees with the row above it
panel.luau:820state.shownis the model's rawmatchedRows, sorow.atis the 24-hour17:48while the row three pixels away renders5:48 PMthroughFmt.hhmm.Parse.timeOfDayaccepts onlyHH:MM, so a 12-hour user typing what they seeis rejected. The toast does name the format, so this is a wart rather than a
trap — but the two surfaces are one glance apart.
A3 —
script_runtime_types.h:39-42names the wrong linespanel.luau:801(and the same citation at:753)The comment reads "Action, Header, Separator (script_runtime_types.h:39-42)".
Those three enumerators are at 33-35, inside
ScriptContextMenuItemKind(32-36); 39-42 is
ScriptContextMenuItem's four fields —kind,id,label,enabled. The argument is sound either way, and 38-43 does support the strongerhalf of it ("an item carries these four things and no others, so it cannot host
an input"). The parenthetical should be
32-36, or38-43if the item's shapeis what is meant.
A4 — "a command that does refetch closes the editor on success" is false
panel.luau:1288-1289runis reached by start, stop,startTag,startRecent, continue, delete andundo. Every one of them refetches and none closes the editor — the B1 probe
shows it surviving a full
refresh()after Stop. OnlysubmitEdit's owndone(ok)closes it. The paragraph's conclusion still stands (typed text isre-supplied from the model, only the caret moves), but the reason given is not
the mechanism. Worth correcting, because it is the sentence that made the
staleness in B1 look impossible.
A5 — a duration past 100 hours outgrows the field
panel.luau:129EDIT_TIME_W = 68leaves 38 px of viewport. The comment budgets for26:00,five glyphs.
Fmt.spanemits100:00— six — for any interval of 100 h ormore, which timew will happily hold (
lengthen @1 PT359996400Swas accepted andran to 2038-01-28). The field is the one the user is about to correct, so it
clipping is the same failure the 52 px screenshot caught, one order of magnitude
out.
A6 — the editor cannot scroll itself into view
panel.luau:1329-1330The editor is 24 + 4 + 24 = 52 px where the row was 20, so opening one on the
last visible row pushes 32 px past the fold.
kScrollexposesstickToBottomand
scrollToBottomRevand no scroll offset at all(
ui_tree_reconciler.cpp:496-499), so there is nothing to fix it with — this isa limit to name in the README beside the other three, not a bug to work around.
Mutation survivors
The author's 31 all die. I found eight more that live, and they cluster in
one place: the editor's geometry and its lifecycle guards. Every mutation was
applied to a fresh copy of the tree and judged on
luau test.luaualone.EDIT_H24 → 20EDIT_TIME_WandEDIT_BTNboth die.focus = trueadded to all three editor fields:1298-1302argues a real property; three nodes plus the card'sfocusOncecan fight for the caret and the suite is silent.gap4 → 0gap4 → 0variantprimary → ghostcancelEditleaveseditBasepopulatedediting == ""is checked first.submitEditdrops thebase.start == nilguardrunChainfiresdone(ok)afterpokeBar/refreshM1 is the one worth acting on: it is the number the PR body defends at length
and the only one of the three the tree does not hold to account. M2 is the one
with the most behaviour behind it.
Everything else I threw held:
timeOfDaywithout itsm > 59bound, withoutits
$anchor, withh > 24, without the%02dnormalisation;spanSecondswith an optional
s, with the1:0203pattern the comment warns about;cmd.editswithPT<n>M, with a zero-delta command, with a space for theT;byStartwithargun-defaulted and with$4unquoted inside the expansion;runChainrefetching on an empty list, markingundoableafter a failure,closing the editor on failure;
beginEditwith a zeroed baseline, a droppeddate, a frozen
editKey;buildListdrawing both the editor and the row;isEditingpinned false;onIpcdefaulting to row 0; the dropped placeholder,the dropped spacer, the dropped
onSubmit.One more thing, not a finding:
0:00in the duration field sendsshorten @id PT<base.seconds>S, and timew accepts it — the interval becomeszero-length rather than being refused. It is what the user typed and one
undoaway, so I would leave it; it is worth a line in the limits list beside the
cleared tag field.
Both corrections above were applied to a scratch copy and run, rather than
proposed on paper:
The regression line is the one that matters: the baseline still decides that an
untouched field on a row whose duration moved under it sends nothing, which is
the property
editBaseexists for. Only the arithmetic moved.Review — the test half (
test.luau), PR #21REQUEST_CHANGES. The suite is strong on the command builder and on the
chain's control flow, and I could not break either. It is weak in one place that
matters: the subtlety the PR body leads with — "the editor keeps a baseline
captured at open" — has no assertion that can tell the baseline from the live
row. Two more user-facing paths are asserted to exist and never exercised.
Method
94 source mutations applied to a throwaway copy, each against a pristine tree
with the baseline re-verified before every run. 27 survived, 26 of them distinct.
Three are equivalent mutants and one is pre-existing coverage, leaving 22 that
change observable behaviour and still print
ok. They cluster into threeblocking gaps and five advisories. Timew claims are checked against a real 1.9.1 database in a
scratch
TIMEWARRIORDB, never the real one.Every fix below is written out; I ran the strengthened suite and it passes on
the current source, and it kills all ten of the survivors I quote.
Blocking
1. The baseline is never distinguished from the live row
test.luau:3803-3820— "The drift the baseline exists to prevent". Its commentmakes two claims and the code only checks one. It checks the flooring: the
field shows
0:01for a 62-second interval, and an unchanged submit must notshorten it by two seconds. It does not check the staleness — that
submitEditreadsstate.editBaserather than the row as it stands now.It cannot, as written. In that block
state.editBase.secondsandstate.shown[3].secondsare both62. Every other editor test has the sameproperty: the list under an open editor never changes duration. Even the refetch
in "Identity, not position" (
test.luau:3764-3774) replays the samePT30MandPT1M2S.So this mutation passes the whole suite,
13 states, 1870 field comparisons,ok:Driving the mutated panel through its own nodes: open the editor on the 12:00
row, land a refetch that reports it as
PT45M, then submit untouched. Thereal source sends nothing. The mutant sends
shorten … PT900S— a resizenobody asked for, on data the user did not touch. That is the exact failure the
PR body describes.
It is not a hypothetical path. Against timewarrior 1.9.1 the running interval is
in the same walk
Timew.cmd.intervalsForDayreads, and its duration ticks:assert(panel.state.editBase.seconds == 62)attest.luau:3812pins that thebaseline is captured. Nothing pins that it is used.
2.
onSubmitis asserted to exist and never firedtest.luau:3540:The message names the failure the assert cannot see. Three mutations survive:
onSubmit = function() endonSubmit = cancelEditonSubmiton the start field onlyThe file already has the right pattern —
enterSearch()attest.luau:1593andenterDraft()attest.luau:1601both fireonSubmit, andtest.luau:2983asserts what it did. The editor section is the one place that stops at the type.
Compare against the confirm button's own handler, and fire it once:
plus, on the live path:
3.
moveis only ever composed for a time earlier than the row's ownBoth
moveassertions use09:15against a12:00row — the unit one attest.luau:805-812and the live one attest.luau:3654-3658. So a date bug thatonly bites when the start is moved forward survives:
ok. This is the same class as the 25-hour bug the whole verb set was chosen toforeclose, and the suite is currently blind to half of it. One line, beside the
existing case:
While you are there: a related behaviour is undecided rather than merely
untested. Driving the real editor on a
2026-08-31T23:50row and typing00:10produces
move @id 2026-08-31T00:10, and timew obliges — verified on 1.9.1,Moved @3 to 2026-08-30T00:10:00, the interval jumps 23h40m backwards. Therow's own date is always right by construction, which is the point; whether it
is right by intent at that boundary is a question the suite does not ask. Worth
a test either way, so the answer is written down. (Raised with the source
reviewer as well.)
Advisory
4.
EDIT_His unasserted, and the height it exists to avoid survivestimew/panel.luau:105-128says plainly that20 px clips its textand that 24is the control tier's minimum. The suite asserts four widths and no height:
EDIT_H = 24 → 20survives,→ 1survives, and deletingheightfrombuildEditRow'sfield()survives. The width asserts show the shape of theright fix; the height just needs one of the same.
5. The time-field floor is set below the width the PR itself computes
test.luau:3572-3577allows any field with 25 px of viewport. The comment twolines above, and
timew/panel.luau:113-127, both put"08:00"at about 34 px atfontSize 12. So
EDIT_TIME_W = 56and= 55both survive — widths the PR's ownarithmetic calls clipping. The guard catches the 52 that the screenshot caught
and nothing between. Raising the floor to the number already written down costs
nothing and closes the gap (68 still passes, 63 does not):
The budget assert at
test.luau:3564is fine — it is not the tautology it lookslike, it fails at
EDIT_TIME_W = 130— but it carries 83 px of slack, soEDIT_BTN = 48, a zero gap and a spacer with noflexGrowall pass it. Thoseare cosmetic and I would not hold the PR for them.
6. The three input keys are never asserted distinct
test.luau:3541asserts each key is a non-empty string;3889-3910assert theyare stable across ticks and fresh on reopen. None asserts they differ from each
other, so
key = "edit-" .. m.editKeyfor all three survives. Two of them aresiblings in the same row, and a handler name is the node's path
(
plugin_bindings.cpp:454-459) — which is the scheme this file polices insweepand in the closure-identity section below the editor. One line:7.
1870 field comparisonsmeans nothing for this featureWorth saying plainly, because the PR body quotes the figure as a gate:
mainat2029e99reports13 states, 1870 field comparisons— byte forbyte the same line. The matrix gained zero comparisons here.
same(appears 65 times before and 65 times after.
isEditingdoes not appear anywhere intest.luau. All 13check()calls run at lines 2419-2703, before the editor section at 3457, sostate.editingis"",editKeyis0andeditAt/editDuration/editTagare
""in every one of them.compare()never reads a row'sisEditingandnever reads the four new top-level model fields.
test.luau:4072-4079istwo-way and does bite — adding an undeclared field to
statefails at"a patch grew state a field no declaration has". The model has nocounterpart:
Viewmodel.buildgrowing a junk top-level field, or a junkper-row field, passes.
The feature is covered, but entirely by the editor section driving the tree —
which is why dropping
editAtfrom the model fails at"the start field opens on the row's own time"rather than at any census. Thatis fine; the figure just should not be read as covering it. A
MODEL_FIELDScensus mirroring
FIELDSwould be the cheap way to make it mean something.8. Untested behaviour worth naming
Checked by driving the real panel, all correct today, none asserted:
2026-08-31T12:00:00 PT30M "deep work" adminparses tothe tag
"deep work" admin— quote characters and all — and that is thestring the field is seeded with. Unchanged, it submits nothing (right).
Changed, it retags to one flat tag, which the README documents. The raw quotes
in the field are not documented anywhere and nothing pins either half.
interval, so neither "its start is editable" nor "its duration is refused"
(
Cannot lengthen open interval @1, confirmed on 1.9.1) is exercised. Samefixture gap as #1.
editBase.dateis the row's own2026-08-28, somovecomposes correctly across days — but nothing asserts it.onConfigChangedwith an editor open.commit()leaveseditingandeditKeyalone, so the caret survives; unasserted.editRow()(
test.luau:3477) returns the first column and#scrollNode().children == 3would still hold if a second row drew one. An
asserton the count of columnsinside
editRowwould close it for free.What I tried to break and could not
Stated so the record is symmetric — these are the questions I went in expecting
to find something and did not:
move,retag,lengthenreversal fails attest.luau:833. Swappingretagwith the resize fails too.PT<n>Sarithmetic. Measuring absolutely, droppingmath.abs, comparingagainst
0instead ofbase.seconds, and sending a zero delta are all killed.lengthen/shortenswapped is killed.byStart's$4. Before the id, unquoted, andargwithout theor ""are all killed.
test.luau:3578and3582compare treevalues against tree values but are not tautologies: changing the editor
column's width alone, or the tag field's alone, fails them.
test.luau:3896.paintsFor. An extra commit on open fails; a commit per keystroke fails.editRow() == nilis a real assertion everywhere itappears;
fields()asserts non-nil at3489;buttons()raises on a nileditor rather than passing. No
fields()read follows anapplywithout anintervening repaint — the two that could have are preceded by
panelEnv.update().#panelJobs == Nin the new section is preceded bya
panelJobs = {}on the triggering line, so none passes on a leftover.takeJobraises on a miss, so draining a refetch by needle does catch apanel that stopped refetching — a mutation removing the post-edit refresh fails
at
takeJob's own error. An extra command leaked only on the editor's successpath is caught too. Three of the five drains do not seal with
assert(#panelJobs == 0)the way3713and3835do; harmless as it stands,but sealing them is free.
differences against
mainare all the menu renumbering (items[3..5]→[4..6]), thebyStartmessage gainingand no $4, the scroll-childrenrewording, and
"the two that are always available"→"the three".assert(goes 479 → 607,fire(13 → 53. Oneshownand oneHOSTILEdeclaration each;
shownmoving to line 153 only widens its scope, and boththe
doblock at 752 and the IIFE at 3457 execute — mutations die inside each.submitEdit'sstate.editing == "" or base.start == nilguard is neverexercised and removing it survives, but a stale handler resolves to nil in the
shell (
luau_host.cpp:3082-3083), so the branch is unreachable. Not a gap.Same for comparing
editDurationuntrimmed:Parse.spanSecondstrims and thezero-delta check absorbs it, so that mutant is equivalent.
Gates on the branch as it stands:
nix shell nixpkgs#luau -c luau test.luau→ok;noctalia plugins lint timew→0 errors, 0 warnings.cfc2e8ec91to03490e273cBoth reviews actioned — every finding, and what happened to it
Branch force-pushed as one commit (
cfc2e8e→2431b81); body rewritten. Gatesin the checkout after the last edit:
The one decision both reviews got half-right
The two reviews proposed opposite fixes for the same line, because
state.editBasewas doing two jobs. Both are now separate and both are pinned:
base.at,base.duration,base.tag). This is what keeps an untouched submit silent on arow whose duration ticks or gets refetched. Replacing
basewholesale (thesibling review's mutation) now fails.
state.shownbybase.start, falling back to the baseline when the row is gone. The frozenvalue was the reachable wrong write: Stop the running row, ask for
1:00on arow that is now
0:45, and the shipped code emittedlengthen PT1800S. Thatnow fails too.
Blocking
editBasenever reconciles; Stop turns it into a silent wrong writelivefound bybase.start,Timew.cmd.edits(live, …). Both guarding tests added: an untouched submit after a refetch that grows the row sends nothing; a touched one sendsPT900Sand notPT1800S. Each review found one half; both mutations now die.onOpenresets the card's field and not the editor'sediting,editAt,editDuration,editTag,editBaseall cleared, with the reason besidedraft's. Test: a half-typed editor plus a reopen leaves no editor drawn, no baseline, no typed text.editKeydeliberately not reset — see survivors below.runrefetches from start, stop,startTag,startRecent, continue, delete and undo and touches none ofstate.editing; onlysubmitEdit's owndone(ok)closes the editor; a reorder moves the editor with its interval because it is drawn whererow.start == state.editing. The surrounding argument (typed text survives a rebuild becauseonChangeapplies to state, so only the caret moves) is kept and still reads correctly.retagbeginEditrecognises the shape andsubmitEditleaves the tag out; the other two fields still work. Reproduced first:timew retag @1 'admin "deep work"'→Note: '"admin \"deep work\""' is a new tag, andtimew tagsthen listsadmin "deep work"for good. Tests: the raw-quote seed, the untouched submit, a typed-over submit that sends themovealone, and a single spaced tag ("deep work") proving it is not this case. README limit corrected — it said "flattens", which understated it; a bareadmin focusset is the case that still flattens, and that is now said separately.onSubmitasserted to exist and never firedonClick(which kills the no-op, thecancelEditswap and the one-field-of-three wiring), and Enter is then fired for real on the live path, asserting theretagit queued and the close when it lands.moveonly ever composed for a start moved earliereditCmds({ at = "18:45" })at the unit level and an 18:45 confirm on the live path. Theif want.at > base.at then "1970-01-01"mutation now dies at the unit assert.Advisory, all applied
EDIT_TIME_Wbudgeted for five glyphs;Fmt.spanemits100:0068 → 76. VerifiedFmt.span(100*3600) == "100:00". The comment's arithmetic is re-measured: 34 px for08:00, ~7 px for a sixth glyph → 41 wanted, 71 + caret = 76. Top line spends 216 ofLIST_W's 283, spacer 67, so nothing is pushed off.>= 5 * 5admits 53–6768passing while100:00clips.68and56both die now.EDIT_Hunassertedf.props.height >= 24on all three fields.24 → 20and a droppedheightboth die.script_runtime_types.h:39-42names the wrong lines32-36at:801. The citation at:753is about the item's field defaults and is correct — left alone.focuson the fields, bothgaps non-zero, confirm'svariantasserted different from cancel's,cancelEdit(and a landed submit) leavingeditBaseempty, thebase.start == nilguard exercised by clearing the baseline under a drawn editor, andrunChain'sdone(ok)proven to fire beforepokeBarby instrumentingnoctalia.state.setand readingstate.editingat the poke.editRow()returns the firstui.column<= 1; abuildListthat draws a second editor dies on that assert with the child count still correct.1870 field comparisonscovers none of this featureMODEL_FIELDScensus added beside the state one, over the model, every list row and every chart bar. A junk top-level, per-row or per-bar field now fails, as does a dropped one.Cannot lengthen open interval @1, re-verified on 1.9.1, chain stopped, editor left open), an editor in all-time scope (base.dateis the row's own2026-08-28, four days off the day scope, andmovecomposes on it), andonConfigChangedwith an editor open (stays open, keeps the typed text, keeps its key).README
Limits list now carries, beside the three that were there: the 24-hour editor
under a 12-hour row (A2 — documented rather than reformatted, see below); the
32 px growth and
kScroll's absent scroll-into-view (A6); the own-date rule andits edge, re-verified here (
23:50row +00:10→Moved @2 to 2026-09-01T00:10:00, 23h40m backwards); and the corrected multi-tag entry.PR body
The
13 states, 1870 field comparisonsquote is gone as a gate, with the reasonstated:
mainprints that line byte for byte, all 13check()calls run beforethe editor section, and
comparenever readsisEditingor the four modelfields. What does cover the feature is named instead — the editor section driving
the tree (82
fire(againstmain's 13; 658 asserts against 479) plus the newcensus — and the mutation count is updated.
Mutations, before and after
onSubmit×3,moveforward)seconds)The three survivors, deliberately:
EDIT_TIME_W76 → 71. 41 px of viewport is exactly the asserted floor; theremaining 5 px is the caret's, which is judgment, not measurement. Pinning it
would freeze a number no API can check.
breakin the live-row lookup. Two intervals cannot share a start — timewforbids overlap — so last-match equals first-match. Equivalent mutant.
onOpenalso resettingeditKey = 0(which the source review's snippetincluded). Unobservable: a closed panel's tree is gone. Left monotonic on
purpose, so a fresh editor cannot reuse a key an earlier one used — which is
why it is not in the five fields
onOpenclears.Considered and declined
Fmt.hhmm. Documented as alimit instead. The inverse of
Fmt.hhmmdoes not exist,Parse.timeOfDaywould have to accept
5:48 PMin every locale's spelling of the meridiem, andthe toast already names the format it wants. A wart, one glance wide, on the
shells where
hour12is true.inexpressible cases (a cleared tag, a zero delta) are silent for the same
reason, and a blocking toast would refuse the other fields with it. Left
silent and documented. This is the one I would most expect a second reviewer to
push back on.
Parse.unquote(row.tag) == row.tag and string.find(row.tag, '"')was written against the raw DOM string;row.taghas already been throughParse.unquoteonce, so the first half canonly fail on a value still quoted end to end after a strip, which timew never
emits (it escapes an inner quote instead of doubling the outer pair). Dropping
it survived the suite as dead code, so it is gone and the comment says why.
0:00in the duration field shortening an interval to zero length. Real,accepted by timew, one
undoaway, and outside this round's scope — worth aREADME line if you want it, say so and I will add it.
assert(#panelJobs == 0). The newdrains follow the existing pattern (
panelJobs = {}on the triggering line, sonone passes on a leftover); two of my new sections do seal. Not tightened
further.
03490e273cto2431b81c652431b81c65to512c57f9abBoth reviews actioned, plus one item they left open. Amended to
512c57f.The two reviews proposed opposite fixes for the same line
ReviewSource proved the frozen
editBase.secondsproduces a wrong delta;ReviewSuite proved that reading the live row instead submits a resize nobody
asked for. Both are right, because
editBasewas doing two jobs on one line:untouched submit silent on a row whose duration ticks or gets refetched)
resize where the user asked)
submitEditnow keepsbasefor the change detection and passes aliverowinto
Timew.cmd.edits. Both halves are separately pinned, and I re-ran bothreviewers' mutations myself: reverting to
edits(base, …)dies, andReviewSuite's wholesale
base = rdies.One item beyond both reviews
ReviewSuite's last note wondered whether a prop existed to make the inert
multi-tag field honest. It does —
enabledis inkInput(
ui_tree_reconciler.cpp:539-545). That field is now rendered disabled ratherthan accepting keystrokes the submit discards, which was the one place this
panel took input and did nothing with it. The two time fields stay live.
Verified independently of the fixer's report
24 mutations re-run by hand in a
/tmpcopy, all dead — both blockers in bothdirections, all eight of ReviewSource's geometry/lifecycle survivors (
EDIT_H24→20,
focusadded, both gaps, confirm'svariant,cancelEditleavingeditBase, thebase.start == nilguard,done(ok)ordering), and all ofReviewSuite's (
onSubmitno-op and swap,EDIT_TIME_W76→68, shared keys, twoeditors drawn, junk model field). Three more for the
enabledchange: droppingit, disabling every field, and pinning the flag false all die.
The new model census earned its place immediately — it failed on
editTagLockedbefore I declared it.Gates in the live checkout:
13 states, 1870 field comparisons/ok, and0 errors, 0 warnings.Accepted survivors
Three, all argued rather than hidden:
EDIT_TIME_W76→71 (the remaining 5 px iscaret slack — a judgment number no API can measure), the
breakin the live-rowlookup (starts are unique because timew forbids overlap, so it is an equivalent
mutant), and
onOpenalso zeroingeditKey(unobservable across a panel close;left monotonic so a fresh editor cannot reuse a key the shell may remember).
Declined, with reasons
enabled = falseabovesupersedes this: the field now refuses the keystroke instead of needing to
apologise for it afterwards.
Fmt.hhmmhas no inverse andParse.timeOfDaywould have to accept every locale's meridiem spelling; thetoast already names the required format.
What a second reviewer should still challenge
EDIT_TIME_W = 76and the 41 px viewport floor are claims about a font, notmeasurements — the same class of claim a screenshot had to falsify at 52 px.
I could not re-verify this one visually:
niri msg --json outputsreturns{}, so the compositor currently has no output andgrimcannot capture. Thearithmetic checks out (top line spends 216 of
LIST_W283; 46 px of viewportagainst ~42 for
100:00, andFmt.span(100*3600)is"100:00") but it isarithmetic, not a picture. The earlier live verification in this PR was done
before the width changed from 68.
Parse.intervalalways unquoting once. It fails safe (refuses the tag rather than corrupting
it), but the coupling is real.
panel.state.editBase = {}directly to reach a branch that isunreachable through the shell — the only place the suite pokes state instead
of driving a handler.