Repository navigation
fix(tui): ignore letter shortcuts when ctrl or alt is held - #5
Closed
eric-engberg wants to merge 22 commits into
Closed
eric-engberg wants to merge 22 commits into
eric-engberg wants to merge 22 commits into
Conversation
This was referenced Oct 3, 2026
Letter shortcuts compared the typed character without checking modifiers, so ctrl+letter and alt+letter ran the plain-letter action: ctrl+r toggled raw value, ctrl+d deleted a widget, and on macOS alt+arrow (sent as ESC + letter) fired whatever letter followed. A few handlers patched ctrl+s one key at a time with `&& !key.ctrl`; widget keybinds checked ctrl but not alt. Shortcuts now compare against getPlainInput(), which is empty while ctrl or alt/option is held, across the line editor, widget keybinds, Edit Colors, Global Overrides, Powerline setup/themes/separators, the line selector and the hide-state checklist. Arrows, Enter, ESC, shift and text entry are unchanged, and modifier combos are free for bindings of their own.
eric-engberg
force-pushed
the
fix/hotkey-modifiers
branch
from
October 3, 2026 22:50
caf659d to
c4cdeb9
Compare
This was referenced Oct 4, 2026
The shortcut tests sent keys and checked the result after a fixed 25 ms. On a busy machine Ink can take longer, so the bare-key cases could fail even though the shortcut works. They now wait until the shortcut's effect shows (up to 3 s), and a follow-up key waits for the previous key's redraw. Checks that a modified key does nothing give it 150 ms to act. After each wait the test also gives React two setImmediate turns: React draws a frame, then attaches the new input listener in a scheduled effect, so a key sent the moment the frame shows could reach no listener.
|
# Conflicts: # src/tui/components/items-editor/input-handlers.ts
eric-engberg
force-pushed
the
fix/hotkey-modifiers
branch
from
October 6, 2026 16:47
d089667 to
dd3dbdf
Compare
eric-engberg
marked this pull request as ready for review
October 6, 2026 18:20
eric-engberg
force-pushed
the
fix/hotkey-modifiers
branch
from
October 7, 2026 01:23
53570ee to
dd3dbdf
Compare
…, save guard return) (sirmalloc#689) * fix(tui): let m clear a merge left on the last widget The merge toggle was guarded to widgets with another widget after them, because the last one has nothing to merge into. But a widget can become last with its merge still set: merge Git Branch into the widget after it, then delete that widget. Git Branch still shows "(merged→)", m does nothing, and "(m)erge" is gone from the help line, so the flag can't be cleared. The next widget added after it then merges into it silently (`⎇ mainModel: Claude` in the preview). On the last widget, m now clears a merge that is set, and the help line offers it there. It still can't set a merge on the last widget, and the merged, merged-without-padding and off cycle is unchanged elsewhere. * fix(tui): keep the other items in order when a move wraps Move mode wraps past either end of the list, but it did so by swapping the moved item with the one at the other end. Moving Model up from the top of Model, Context Length, Git Branch, Git Changes gave Git Changes, Context Length, Git Branch, Model: Model reached the bottom, but Git Changes jumped from the bottom to the top. The line selector's move mode did the same with whole lines. Both move modes now take the item out and put it back in at the target, so wrapping past either end moves only that item and the rest keep their order. A move to a neighbour is still a swap. * fix(tui): return to the current screen from the invalid-settings save guard When settings.json is invalid, Ctrl+S first asks before overwriting it. The question can come up on any screen, but both answers went to the main menu: with Edit Line 1 open, Ctrl+S then ESC (or Yes) left the line editor, and you had to navigate back in to carry on. The guard now returns to the screen Ctrl+S was pressed on, for both answers. Save & Exit, the other route into the guard, starts from the main menu and still returns there. The screen comes back as it does when you navigate to it: a sub-editor that was open on it, such as the label editor, and its unsaved text aren't restored. * test(tui): put chalk's color level back after a whole-App test The App sets chalk's global color level from the settings it loads, and the whole-App test helper didn't restore it. Test files that run later in the same process then rendered at that level instead of their own: a No Color check could see 256-color codes. The sandbox now restores the level along with the mocks and the config path.
…ers (sirmalloc#688) * fix(jj-bookmarks): keep bookmarks on different heads apart JJ Bookmarks lists the bookmarks of heads(::@ & bookmarks()) with the template "bookmarks". jj log --no-graph prints each commit's template output back to back with nothing in between, so when @ sits on a merge of two bookmarked heads the names ran together: "feature-afeature-b". The template now ends each head's output with a newline, which the whitespace split already handles; bookmarks on one commit were always space-separated and still are. * fix(speed): switch to "k" at the rounding boundary formatSpeed switched to "k" at exactly 1000 t/s, but values just below round up when shown: 999.96 t/s rendered as "1000.0 t/s" instead of "1.0k t/s" (and 999.6 as "1000 t/s" with whole numbers). It now switches once the value would round to 1000 at the shown precision, 1000 - 0.5 / 10^decimals, the same boundary formatTokens uses for "M". * fix(usage): show "time cursor" only on widgets that draw one The time cursor is a usage bar setting (cursor metadata), and the widget picker keeps metadata when it changes a widget's type. After changing a usage bar with the cursor on to Block Timer, Block Reset Timer or Extra Usage Utilization, the editor row said "time cursor", though those widgets never draw one and have no key to turn it off. The modifier text now lists the cursor only for widgets that ask for it, as it already does for compact and date: the usage percent widgets, which draw it. The metadata itself is left alone, so changing back to a usage bar keeps the setting. * refactor(jj-bookmarks): write the template as a raw string String.raw shows the template exactly as jj reads it, "\n" included, instead of a doubled backslash. * test(usage): check only that a carried-over cursor isn't shown The three time-cursor tests compared the whole modifier text, so they'd also break on any change to how the bar mode itself is described. They now check the part they're about: the bar mode still shows, and the time cursor doesn't.
…fter its resets (sirmalloc#687) * fix(renderer): keep a preserve-colors widget's Powerline background after its resets In Powerline mode a widget that preserves its own colors (a Custom Command with preserve colors on) gets its segment background once, before its text. Command output usually ends with \x1b[0m, and Custom Command appends one after cutting colored output to its max width; that reset cleared the background too, so the trailing padding, and any text after a reset mid-output, showed the terminal's background before the separator arrow: \x1b[48;5;26m \x1b[31mhello\x1b[0m \x1b[0m<arrow> Re-apply the segment background after each SGR sequence in the preserved text that clears it: a full reset (0 or an empty parameter, alone or combined like 0;32) or 49. The arguments of extended colors (38/48/58;5;n and ;2;r;g;b) are skipped, so a 0 or 49 among them isn't read as a reset. The command's own colors, including its own backgrounds, are kept, and the full reset after the widget is unchanged. Widgets without a background, and plain mode (which colors the padding separately), are untouched. * fix(renderer): honor backgrounds set after resets in combined SGR codes Powerline widgets with preserveColors enabled restored the segment background whenever an SGR sequence contained a reset. For output such as ESC[0;41m, that restoration overwrote the explicit red background set later in the same sequence. Indexed and truecolor backgrounds were also affected. Process all SGR parameters in order and track the final background operation. Restore the segment background only when the sequence leaves it cleared; preserve standard, bright, indexed, and truecolor backgrounds set after a reset. Continue skipping extended color arguments so channel values such as 0 and 49 are not interpreted as background operations. Add parameterized helper and Powerline renderer regression coverage for reset-before-color and color-before-reset ordering, empty reset parameters, extended color arguments, and restoration after a subsequent reset. Confirmed 16 new cases fail before the fix. Validation: - bun test: 2811 passed, 0 failed - Targeted ANSI and Powerline tests: 63 passed, 0 failed - bun run lint: TypeScript and ESLint passed - git diff --check passed --------- Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
…loc#686) * fix(colors): stop number keys from leaving Edit Colors ink-select-input selects the item at a digit key's position, the same as Enter on it. Edit Colors ignored digits in its own key handler, but its list still passed them to onSelect, and choosing the Back entry there called onBack. So on a line with two colorable widgets, pressing 3 left the screen; with eight, 9 did. Edit Colors now handles Enter on Back itself and gives the list no onSelect, so digits do nothing, as the handler already intended. Enter on a widget still does nothing, and Enter on Back and ESC still go back. * fix(colors): keep a live highlight when hiding separators With separators shown in Edit Colors, highlighting one and pressing s again hid it but left the highlight pointing at it. The list redrew with the marker on the first item, but every key that acts on the highlighted item looked up the hidden separator and found nothing: b, d, r and left/right did nothing, a color entered after a, h or g was dropped, and the "Current foreground" line went blank. When hiding separators takes away the highlighted one, the highlight now moves to the first widget, where the list already draws the marker. Showing separators, or hiding them while a widget is highlighted, keeps the highlight where it was.
…ry segment count (sirmalloc#685) With abbreviate-home on, the leading "~" counted as one of the kept segments. ~/a/b with segments=2 had three parts, so it rendered "~/.../a/b" though nothing was left out; /a/b with segments=2 renders "/a/b". The "~" is always kept in front, so it no longer counts. The preview was a set of fixed strings for one or two segments, so segments=3 or 4 still previewed two (".../example/project"), and those strings didn't come from the sample path itself. The preview now runs the same segment logic over the sample path that a real path goes through, so it shows what the setting produces.
…rmalloc#684) The status payload schema accepted null for context_window, effort, vim, worktree and rate_limits, but not for version, cost, workspace or output_style. A payload with any of those four set to null failed validation as a whole: the CLI printed "Invalid status JSON format" and exited 1, so no status line rendered, even when no widget on the line reads that field. Mark the four fields nullable like the others. Every reader already goes through optional chaining or a truthiness check (Version, Session Cost, Session Clock, Output Style, and the git and Claude settings lookups that fall back from workspace to cwd), so a null field renders the same as a missing one. Wrong types are still rejected.
…e-dated usage cache (sirmalloc#683) * fix(usage): time out keychain reads and back off when no login is found On macOS the usage credentials come from `security find-generic-password` and, for the default profile on a miss, `security dump-keychain`. Neither call had a timeout, so a `security` that blocks (for example on a keychain unlock prompt) held the whole status line until someone answered it. With a fake `security` that sleeps 12s, every render took 24s. Both calls now time out after 5s, like the git and usage API calls. A lookup that finds no OAuth login was also repeated on every render: an API-key user with a usage widget has nothing to cache, so each render ran both `security` calls, the full keychain dump included. The miss is now remembered for 30s across renders (as it already was within one process), in ~/.cache/ccstatusline/usage-credentials.lock. The record names the credential stores it covers (the keychain service and config directory), so one profile's miss never suppresses another profile's lookup. As with the usage lock, a deadline further ahead than the backoff itself is ignored, so a clock set back cannot stretch it. With the same fake `security`, the first render now takes 10s and the next ones inside the backoff about 0.2s. * fix(usage): stop counting a usage cache dated in the future as fresh The file cache is fresh while `now - mtime` is under 180s. For a file modified in the future that age is negative, so it always passed. After one render under a clock running ahead (since corrected, e.g. by NTP or a VM resume), the cached usage stayed on screen with no refetch until real time passed that mtime plus three minutes: a clock a day fast froze the usage widgets for a day. A file dated ahead of now now counts as fresh only within one cache lifetime. That still covers a concurrent render writing the file just after this one read the clock; anything further ahead cannot be right and is refetched. The usage lock already ignores deadlines too far ahead for the same reason. * fix(terminal): report no width instead of tput's default of 80 When the ancestor walk finds no terminal, the probe fell back to `tput cols`. That runs with piped stdio, so tput cannot see a terminal either: it prints $COLUMNS if exported, otherwise the terminfo default for $TERM, which is 80. So with no terminal found and TERM set, the status line was truncated at 74 columns in full-width mode (40 in full-minus-40) however wide the real terminal was, and flex separators padded to that made-up width. It also meant the probe never returned null in that case, so the per-session "no TTY" cache was never written and the whole `ps` ancestor walk re-ran on every render. The fallback is gone. With no terminal found the probe returns null, which the renderer already handles: no truncation, and flex separators render as plain separators. That result is now cached per session as intended. CCSTATUSLINE_WIDTH still overrides detection for anyone who wants a fixed width.
) Edit Colors' (r)eset and (c)lear all removed each widget's numberFormat along with its colors, bold and dim. Precision used to be cycled from Edit Colors, but it moved to Edit Lines (the `.` key), and the reset paths kept clearing it. So resetting a widget's colors silently undid its number style: a Context Length set to whole numbers went from `Ctx: 19k` back to `Ctx: 18.6k`, though neither the key help nor the clear-all confirmation mentions anything but colors. Reset and clear all now remove only color, background, bold and dim.
…it (sirmalloc#681) * fix(tui): keep the TUI usable after updating the pinned install from it Running a global update from Check for Updates records the new version as the pinned install. On the next render the TUI, still the older version, saw a pinned install newer than itself and replaced everything with the "Pinned Install Version Mismatch" screen, whose only option is Exit. Ctrl+S is a no-op on that screen, so any edits made before the update were lost. That screen exists so a TUI doesn't write settings for a pinned runtime of a different version. Here, though, the settings being edited were loaded before the update, so saving them afterwards is the same as saving a moment earlier. The TUI now remembers the version its own update installed and doesn't treat that version as a mismatch for the rest of the session, so the menus and both save paths stay available. A fresh launch of the older TUI still gets the mismatch screen. * test(tui): put chalk's color level back after a whole-App test The App sets chalk's global color level from the settings it loads, and the whole-App test helper didn't restore it. Test files that run later in the same process then rendered at that level instead of their own: a No Color check could see 256-color codes. The sandbox now restores the level along with the mocks and the config path. --------- Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
… as v1 (sirmalloc#680) loadSettings treats a file as v1 only when it has no `version` key, but needsMigration/detectVersion treat any `version` that isn't a number as v1. A current config with `"version": "4"` or `"version": null` (after a hand edit, for example) therefore skipped v1 validation and then ran the v1->v2 migration anyway. That migration rebuilds the config from `lines` and the v1 fields only, so the result validated and was written back over the file: Powerline, defaultPaddingSide, minimalistMode, separators and every other v2+ setting were dropped, widget ids were regenerated, and the status line announced "ccstatusline updated to v2.0.2". needsMigration now reports no migration for a version that is present but not a number. The config then reaches schema validation, which rejects it, so it takes the existing invalid-file path: defaults in memory, the load error recorded for the warning badge and TUI banner, and the file left untouched. Import validation shares needsMigration, so an import file with a non-numeric version is now rejected instead of being reduced to its v1 fields.
…nd wide PR titles (sirmalloc#679) * fix(git-branch): show detached HEADs and branches shadowed by a tag On a detached HEAD (a tag checkout, a rebase in progress, a bisect), `git symbolic-ref` fails, so Git Branch showed "no git" (or hid itself with hide-no-git) inside a perfectly good work tree. It now shows the short commit in parentheses, as git prompts do, e.g. "⎇ (0449a12)", and the repo link points at that commit. When a tag has the same name as the current branch, `symbolic-ref --short` disambiguates the name to "heads/<branch>", so the widget showed "⎇ heads/release" and linked to .../tree/heads/release. It now reads the full ref and strips "refs/heads/", which is always the plain branch name. * fix(git-worktree): recognize the main worktree in submodules and separate git dirs Git Worktree decided "main worktree" by the git dir's path: ".git" at the end, else a ".../worktrees/<name>" pattern. A submodule's git dir is .git/modules/<name> and a --separate-git-dir repo's is wherever it was put (e.g. /x/repo.git), so both showed "no git". The path pattern could also misread a repo kept under a directory named "worktrees". It now asks git for both the git dir and the common dir in one call. A linked worktree's git dir is always <common dir>/worktrees/<name>; any other git dir belongs to the main worktree. * fix(git-pr): truncate titles by display width and whole characters PR titles were cut with title.slice(0, 29), counting UTF-16 code units. An emoji at the cut lost its second half and showed as U+FFFD ("�"), and a CJK title passed the 30-unit limit while taking 59 columns, since each character is two columns wide. Truncation now goes through the same display-cluster and width logic the renderer uses for max widths, keeping the 30-column limit and the single "…" ellipsis. * refactor(git-worktree): swap path separators with replaceAll A plain replaceAll says what the line does without a global regex. * fix(git): preserve Node.js 14 worktree path normalization --------- Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
…rs at No Color (sirmalloc#678) * fix(renderer): keep truncating when the reserved width leaves no columns Full minus 40, and Full until compact past its threshold, subtract 40 columns from the terminal width; Full subtracts 6. On a narrow terminal that went to 0 or below, and every width check treated a non-positive width as "no width detected": the line printed at full length and wrapped, while 41 columns still truncated to a single ".". The TUI preview cut the line at the terminal edge instead, so it no longer matched what Claude Code received. Clamp the effective width at 0 and treat any detected width, 0 included, as known, in both the plain and Powerline renderers. When the reserve takes the whole terminal the line now truncates to nothing, the continuation of what 41-43 columns already show. An undetected width (null, or 0 from the caller) keeps the fallback behavior. * fix(renderer): stop reading widget text FLEX as a flex separator The plain renderer stood each flex separator in the assembled line as the string 'FLEX' and later split the line on that string. A widget whose rendered text was exactly FLEX with no color codes around it (No Color mode, or a color name the palette doesn't have) matched too: its text vanished and the line got a second flex gap. Push null for a flex separator instead, which no widget output can equal. Powerline mode already uses its own marker and is unchanged. * fix(colors): emit no 256-color or truecolor escapes at Basic and No Color getColorAnsiCode returned raw 256-color and truecolor escapes for hex: and ansi256: colors at every color level. With No Color selected, which the TUI describes as disabling all color output, a widget colored hex:FF0000 on hex:0000FF still printed \x1b[48;2;0;0;255m\x1b[38;2;255;0;0m; Basic did the same. Return no code for these formats at the ansi16 level (Basic and No Color), as the function already does for gradient specs. This matches the TUI, which only offers these formats at 256 Color and Truecolor and clears them from widgets when switching to Basic or No Color, so they only reach these modes through a hand-edited config. Named colors are unchanged. * refactor(renderer): flatten the no-width flex fallback branch The else branch re-checked terminalWidth === null, which the preceding condition (a flex separator and a known width) already implies when a flex separator is present. Chain it as else-if on hasFlexSeparator instead of nesting an if/else inside the else. * test(custom-command): start each runtime once before its capture tests On a fresh CI machine the first start of a runtime can take seconds while its binary is read from a cold disk. The capture tests start Node for the first time in their opening case, so that case once ran past its 5s limit (5018 ms) while the same case one test later took 146 ms and the rest about 60 ms. Each runtime now starts once in a setup hook with its own timeout, before the timed tests.
…c#677) Customizing a Powerline theme (c in the theme list) copied the theme's colors onto the widgets by counting every widget in a line, restarting at the first color on each line. The renderer counts differently: a widget merged into the next one shares that group's color, and with Continue Theme on, each line picks up where the previous one stopped. So the copy could differ from what the theme had just shown. With Model merged into Context Length, the pair shared one background under the theme, but the copy gave Context Length the next color and shifted every widget after it; with Continue Theme on, every line after the first restarted at the first color. Number the widgets with the same slot rule the renderer uses to carry the theme across lines, now shared from powerline-theme-index, and continue the numbering across lines when Continue Theme is on. Lines without merges and with Continue Theme off get the same colors as before.
…s too (sirmalloc#670) * feat: label Extra Usage as spend on accounts without plan limits On usage-based plans (Claude Enterprise) the usage API reports every rate-limit window as null and no limits[] entries: there's no plan limit to go over, and extra usage is the account's whole spend. The Extra Usage widgets still called it overage ("Overage Used: $123.45"), and their picker descriptions only mentioned pay-as-you-go overage, so Enterprise users had no reason to think these widgets were theirs. Searching the picker for "enterprise" or "spend" didn't find them. The parser now flags a response that explicitly reports no plan limits (`noPlanLimits`). The flag is kept in the usage cache and through the merge with statusline rate_limits, and the three widgets label their value "Spend Used", "Spend Left" and "Spend" when it's set. Only an explicit null counts and any limits[] entry counts as a limit, so every uncertain case keeps the "Overage" label. A cache written before this change keeps the old label until its next refresh. The widget descriptions now mention both Pro/Max overage and Enterprise spend, which also makes the picker's search find them. * test: cover the Spend label with an edited label Upstream's label editor (sirmalloc#601) gives each widget one default label, while Extra Usage Used, Remaining and Utilization pick "Overage" or "Spend" by account. The editor offers the Overage label, since it has no usage data, and an edited label replaces whichever one the account would show. * refactor: keep the Enterprise wording, drop the Spend label detection With the label editor (sirmalloc#601) in main, anyone on a usage-based Enterprise plan can relabel the Extra Usage widgets' "Overage" to "Spend" in a few keys, so detecting those accounts from the usage API's response shape isn't worth its cache field and heuristics. What stays is the wording: the three widgets' descriptions say they cover Pro/Max overage and Enterprise spend, the docs say the same and point at the label editor, and picker searches for "enterprise" or "spend" list the three widgets first, which a new test checks. * test: check the picker lists the Extra Usage widgets first, however many there are * test: look for the Extra Usage widgets among the widgets that mention the word * refactor(widgets): share Extra Usage Used and Remaining's code The two widgets were the same class apart from how the amount is worked out, and their label, name, description and preview sample. They now extend ExtraUsageAmountWidget, the way the Tokens widgets share TokenCountWidget. Rendering is unchanged.
…rmalloc#668) Until the first extra-usage charge of the month, the usage API reports `utilization: null` alongside the monthly limit and the amount spent ($0). The widget treated the missing utilization as no data and rendered nothing, so it looked broken on every account with extra usage enabled until the first charge. When the API reports no utilization, work it out from the amount spent and the monthly limit. An API-reported value still wins, and accounts without a monthly limit render nothing, as before. Co-authored-by: Matthew Breedlove <sirmalloc@gmail.com>
…malloc#673) The separator editor wrote `separatorInvertBackground[i]` straight into a copy of the saved list. That list is often shorter than the separators: its schema default is `[false]`, and hand-edited or imported configs commonly add separators without extending it. Toggling (t) or cycling (left/right) a separator past the end of the list left holes in the array, which JSON saves as `null`. With three separators and the default list, selecting the third and pressing `t` saved `[false, null, true]`; the next load failed validation, so both the TUI and the status line fell back to defaults with the invalid-config warning. Pad the list to one entry per separator, defaulting to false, before any edit. Missing entries already rendered as not inverted, so nothing changes visually; add, insert and delete now also work on aligned entries.
* feat(tui): edit a widget's label with e The label editor opened with b (la(b)el…). Custom Text, Custom Command, Custom Symbol and Link already open their text editors with e, so the line editor's "edit this widget's text" key differed by widget. Labels now use e too, shown as (e)dit label…, which also reads more plainly. None of the widgets that bind e has a label, so e is free wherever the label editor appears; the existing check that labeled widgets leave the label key unbound now runs against e. * docs: name the e key where the label editor hides in raw value mode The label key moved to e, but one sentence still said b is hidden while raw value is on.
…alloc#667) * fix: show the max width in Git Branch and Git Root Dir previews (w) caps how wide the branch or repository name gets, but the TUI preview returned its sample before applying the cap, and the samples (`main`, `my-repo`) were too short to cut anyway. With a cap set, the previews now use a longer sample and cut it the way the status line cuts a real name, so the setting shows as you make it. * refactor: move Git Branch's preview into its own method render() had grown a preview branch with its own sample, width and link handling; it reads more easily on its own.
# Conflicts: # src/tui/components/ColorMenu.tsx # src/tui/components/LineSelector.tsx # src/tui/components/PowerlineThemeSelector.tsx # src/tui/components/items-editor/input-handlers.ts
|
Owner
Author
|
Merged upstream as sirmalloc#669. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



Problem
Letter shortcuts compared the typed character without checking modifiers, so a ctrl or alt combo ran the plain-letter action:
ESC b/ESC f, which Ink reads as alt+b / alt+f. Word-jumping with option-arrow could therefore toggle bold, background mode, and so on.&& !key.ctrl. Widget keybinds checked ctrl but not alt.Fix
getPlainInput(input, key)inutils/input-guards.tsreturns the typed character only for a bare keypress, and''while ctrl or alt/option is held. Every letter shortcut now compares against it:These are unchanged:
A/ahandlers keep workingshouldInsertInputThe ad-hoc
!key.ctrlpatches are gone, and modifier combos are free for bindings of their own.Modifiers on macOS and Windows/Linux
Ink 6.2 reports
ctrl,meta(alt/option) andshift:ctrlandmetacovers Ctrl, PC Alt, and Mac Option when "Option as Meta/Esc+" is on.∫,®, …), which never matches a shortcut letter.Testing
getPlainInputunit tests.a i d k c r mdo nothing, the bare letter still works, and widget keybinds ignore alt as well as ctrl.hotkey-modifiers.test.ts: a table-driven Ink test that sends the real terminal bytes. Ctrl is the control character, e.g.\x12for ctrl+r; alt isESC+ key. For one shortcut in each of the seven components, it checks that the bare key fires and ctrl and alt don't. ctrl+i isn't used because it's the same byte as Tab.bun test: 2457 pass.bun run lint: clean.M-andC-sequences: alt+b, ctrl+r and alt+d do nothing in the line editor, while plainbandrwork.