@brimveyn/aimux 1.23.6 → 1.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/package.json +2 -2
  2. package/src/app-runtime/tab-actions.ts +7 -1
  3. package/src/input/keymap/key-chord.ts +10 -0
  4. package/src/input/keymap/key-format.ts +14 -1
  5. package/src/input/modes/handlers/shared.ts +48 -0
  6. package/src/pty/command-registry.ts +35 -0
  7. package/src/state/actions.ts +2 -1
  8. package/src/state/project-persistence.ts +16 -1
  9. package/src/state/reducers/modal-state.ts +24 -28
  10. package/src/state/reducers/tab-state.ts +14 -3
  11. package/src/state/text-cursor.ts +117 -0
  12. package/src/ui/breaking-update-screen.tsx +7 -1
  13. package/src/ui/components/git/pane/pr-checks-panel.tsx +7 -2
  14. package/src/ui/components/layout/bar-footer.tsx +3 -5
  15. package/src/ui/components/layout/bar.tsx +41 -16
  16. package/src/ui/components/layout/sidebar/project-list.tsx +6 -16
  17. package/src/ui/components/layout/sidebar/workspace-row.tsx +6 -9
  18. package/src/ui/components/layout/status-bar.tsx +9 -3
  19. package/src/ui/components/layout/terminal-pane.tsx +18 -16
  20. package/src/ui/components/modals/app/help-modal.tsx +20 -15
  21. package/src/ui/components/modals/app/update-available-modal.tsx +3 -1
  22. package/src/ui/components/modals/git/git-commit-modal.tsx +2 -2
  23. package/src/ui/components/modals/projects/create-project-modal.tsx +9 -3
  24. package/src/ui/components/modals/projects/project-picker-modal.tsx +6 -6
  25. package/src/ui/components/modals/settings/settings-search-modal.tsx +19 -14
  26. package/src/ui/components/modals/shared/modal-shell.tsx +12 -2
  27. package/src/ui/components/modals/shared/picker.tsx +8 -4
  28. package/src/ui/components/modals/snippets/snippet-picker-modal.tsx +7 -5
  29. package/src/ui/components/modals/tabs/new-tab-modal.tsx +10 -12
  30. package/src/ui/components/modals/themes/theme-picker-modal.tsx +14 -10
  31. package/src/ui/components/modals/workspace/create-workspace-modal.tsx +19 -14
  32. package/src/ui/components/modals/workspace/workspace-move-modal.tsx +4 -2
  33. package/src/ui/components/overlays/context-menu/context-menu-overlay.tsx +10 -4
  34. package/src/ui/components/primitives/list-item.tsx +24 -2
  35. package/src/ui/components/primitives/surface.tsx +4 -1
  36. package/src/ui/components/settings/row-value.tsx +8 -1
  37. package/src/ui/components/settings/settings-footer.tsx +3 -2
  38. package/src/ui/components/settings/settings-row.tsx +3 -0
  39. package/src/ui/components/settings/settings-search-bar.tsx +4 -6
  40. package/src/ui/components/settings/settings-view.tsx +25 -21
  41. package/src/ui/components/stats/stats-view.tsx +4 -2
  42. package/src/ui/selection-ink.ts +20 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brimveyn/aimux",
3
- "version": "1.23.6",
3
+ "version": "1.24.0",
4
4
  "description": "A terminal multiplexer for AI CLIs. Run Claude, Codex, OpenCode, Kimi side-by-side with tabbed navigation, split panes, and persistent sessions.",
5
5
  "keywords": [
6
6
  "ai",
@@ -66,7 +66,7 @@
66
66
  "bump:terminal_manager": "bun run scripts/bump-protocol.ts terminal-manager"
67
67
  },
68
68
  "dependencies": {
69
- "@brimveyn/aimux-config": "0.10.7",
69
+ "@brimveyn/aimux-config": "0.10.8",
70
70
  "@opentui/core": "^0.1.90",
71
71
  "@opentui/react": "^0.1.90",
72
72
  "@resvg/resvg-wasm": "^2.6.2",
@@ -10,6 +10,7 @@ import {
10
10
  getAssistantOption,
11
11
  isCommandAvailable,
12
12
  parseCommand,
13
+ stripInjectedSessionArgs,
13
14
  } from '../pty/command-registry'
14
15
  import { createTerminalBounds } from '../state/layout-resize'
15
16
  import {
@@ -116,6 +117,11 @@ export function startTabSession(
116
117
  ctx.startStartupGrace(tab.id, STARTUP_GRACE_MS)
117
118
 
118
119
  const { args, executable } = parseCommand(tab.command)
120
+ // `tab.command` is not always the string this client wrote: a hydrate from the
121
+ // daemon (or a snapshot taken after one) hands back the whole argv of the last
122
+ // spawn, session flags included. Strip ours back out before adding this
123
+ // spawn's, or claude exits on the duplicate and takes the tab with it.
124
+ const baseArgs = stripInjectedSessionArgs(tab.assistant, ctx.state.customCommands, args)
119
125
  // Ahead of `extraArgs` because that is where an initial prompt goes, and the
120
126
  // prompt is positional — a flag after it would be read as part of it.
121
127
  const sessionArgs =
@@ -134,7 +140,7 @@ export function startTabSession(
134
140
  }
135
141
 
136
142
  backend.createSession({
137
- args: [...args, ...sessionArgs, ...(extraArgs ?? [])],
143
+ args: [...baseArgs, ...sessionArgs, ...(extraArgs ?? [])],
138
144
  assistant: tab.assistant,
139
145
  autoRenameCandidate,
140
146
  cols,
@@ -11,8 +11,11 @@ export type KeyChord = string
11
11
  const SPECIAL_NAMES: Record<string, string> = {
12
12
  BS: 'backspace',
13
13
  CR: 'return',
14
+ Del: 'delete',
14
15
  Down: 'down',
16
+ End: 'end',
15
17
  Esc: 'escape',
18
+ Home: 'home',
16
19
  Left: 'left',
17
20
  Right: 'right',
18
21
  Space: 'space',
@@ -38,6 +41,12 @@ export function keyInputToChord(key: KeyInput): KeyChord {
38
41
  let prefix = ''
39
42
  if (ctrl) prefix += 'C-'
40
43
  if (meta) prefix += 'M-'
44
+ // Only for named keys. On a single character shift is already in the character
45
+ // itself (handled above), so an `S-` there would be a chord nothing produces.
46
+ // Whether Shift+Enter arrives at all is the terminal's call — it needs the
47
+ // kitty keyboard protocol or modifyOtherThanKeys to report the modifier, and
48
+ // where it does not the Ctrl+Enter binding beside it still fires.
49
+ if (shift && baseName.length > 1) prefix += 'S-'
41
50
 
42
51
  return `${prefix}${baseName}`
43
52
  }
@@ -136,6 +145,7 @@ function parseAngleBracketToken(inner: string): KeyChord {
136
145
  let prefix = ''
137
146
  if (ctrl) prefix += 'C-'
138
147
  if (meta) prefix += 'M-'
148
+ if (shift && keyName.length > 1) prefix += 'S-'
139
149
 
140
150
  return `${prefix}${keyName}`
141
151
  }
@@ -2,8 +2,11 @@ import { type KeyChord, parseKeyNotation } from './key-chord'
2
2
 
3
3
  const SPECIAL_DISPLAY: Record<string, string> = {
4
4
  backspace: 'Backspace',
5
+ delete: 'Del',
5
6
  down: '↓',
7
+ end: 'End',
6
8
  escape: 'Esc',
9
+ home: 'Home',
7
10
  left: '←',
8
11
  return: 'Enter',
9
12
  right: '→',
@@ -25,6 +28,10 @@ export function formatChord(chord: KeyChord, leader?: KeyChord): string {
25
28
 
26
29
  let ctrl = false
27
30
  let meta = false
31
+ // Shift rides as an `S-` prefix on named keys (`S-return`), and as an
32
+ // uppercase letter on the rest. Both end up as "Shift+…" here; without this
33
+ // the help overlay printed the raw chord.
34
+ let shift = false
28
35
  let rest = chord
29
36
 
30
37
  while (true) {
@@ -38,10 +45,15 @@ export function formatChord(chord: KeyChord, leader?: KeyChord): string {
38
45
  rest = rest.slice(2)
39
46
  continue
40
47
  }
48
+ if (rest.startsWith('S-')) {
49
+ shift = true
50
+ rest = rest.slice(2)
51
+ continue
52
+ }
41
53
  break
42
54
  }
43
55
 
44
- if (!ctrl && !meta) {
56
+ if (!ctrl && !meta && !shift) {
45
57
  if (rest.length === 1 && /^[A-Z]$/.test(rest)) {
46
58
  return `Shift+${rest}`
47
59
  }
@@ -51,6 +63,7 @@ export function formatChord(chord: KeyChord, leader?: KeyChord): string {
51
63
  const parts: string[] = []
52
64
  if (ctrl) parts.push('Ctrl')
53
65
  if (meta) parts.push('Alt')
66
+ if (shift) parts.push('Shift')
54
67
  parts.push(formatModifierKey(rest))
55
68
  return parts.join('+')
56
69
  }
@@ -40,11 +40,59 @@ export function handleModalSelectionKeys(
40
40
  return null
41
41
  }
42
42
 
43
+ /**
44
+ * Cursor motion for a key no binding claimed.
45
+ *
46
+ * Every modal that accepts typing is a `passthrough()` mode, and this is the one
47
+ * function they all fall through to — so a motion added here reaches every text
48
+ * field in the app at once, without a binding per modal. The modals that do bind
49
+ * an arrow (the update prompt's row of buttons, the git and settings screens)
50
+ * resolve it before it ever gets here.
51
+ */
52
+ function handleCursorMotion(key: KeyInput): KeyResult | null {
53
+ const word = key.ctrl || key.meta
54
+ switch (key.name) {
55
+ case 'left':
56
+ return result([
57
+ word
58
+ ? { to: 'word-left', type: 'move-modal-cursor' }
59
+ : { delta: -1, type: 'move-modal-cursor' },
60
+ ])
61
+ case 'right':
62
+ return result([
63
+ word
64
+ ? { to: 'word-right', type: 'move-modal-cursor' }
65
+ : { delta: 1, type: 'move-modal-cursor' },
66
+ ])
67
+ case 'home':
68
+ return result([{ to: 'home', type: 'move-modal-cursor' }])
69
+ case 'end':
70
+ return result([{ to: 'end', type: 'move-modal-cursor' }])
71
+ // Only reached in a modal that leaves Up/Down unbound — the ones that drive a
72
+ // list with them hand their own action a `moveModalCursorInField` fallback.
73
+ case 'up':
74
+ return result([{ to: 'line-up', type: 'move-modal-cursor' }])
75
+ case 'down':
76
+ return result([{ to: 'line-down', type: 'move-modal-cursor' }])
77
+ default:
78
+ return null
79
+ }
80
+ }
81
+
43
82
  export function handleTextInput(key: KeyInput): KeyResult | null {
83
+ const motion = handleCursorMotion(key)
84
+ if (motion) return motion
85
+
44
86
  if (key.name === 'backspace') {
45
87
  return result([{ char: '\b', type: 'update-command-edit' }])
46
88
  }
47
89
 
90
+ // DEL, the counterpart to backspace: both are commands rather than text
91
+ // because neither can arrive as a typed character.
92
+ if (key.name === 'delete') {
93
+ return result([{ char: '\x7f', type: 'update-command-edit' }])
94
+ }
95
+
48
96
  if (key.name === 'space') {
49
97
  return result([{ char: ' ', type: 'update-command-edit' }])
50
98
  }
@@ -251,6 +251,41 @@ export function buildAssistantSessionArgs(
251
251
  : option.session.buildSessionArgs(sessionId)
252
252
  }
253
253
 
254
+ const SESSION_FLAG = /^(?:-r|--resume|--session-id)$/
255
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
256
+
257
+ /**
258
+ * Drop the session flags an earlier spawn left inside `tab.command`.
259
+ *
260
+ * The daemon stores its tabs as `[command, ...args].join(' ')`, so a client that
261
+ * hydrated from it — or a snapshot written by one — carries this tab's own
262
+ * `--session-id <uuid>` in the string the next spawn parses. Re-spawning that
263
+ * verbatim is `Error: Session ID … is already in use`, and an exiting PTY takes
264
+ * the tab with it.
265
+ *
266
+ * Only a uuid-shaped value is ours, and only when the user's own custom command
267
+ * does not declare that flag — a `--resume` they wrote themselves is theirs to
268
+ * keep, and `buildAssistantSessionArgs` already stands aside for it.
269
+ */
270
+ export function stripInjectedSessionArgs(
271
+ assistant: AssistantId,
272
+ customCommands: Record<string, string>,
273
+ args: string[]
274
+ ): string[] {
275
+ const custom = customCommands[assistant] ?? ''
276
+ if (/(^|\s)(-r|--resume|--session-id|--continue|-c)(\s|$)/.test(custom)) return args
277
+ const kept: string[] = []
278
+ for (let i = 0; i < args.length; i++) {
279
+ const arg = args[i] as string
280
+ if (SESSION_FLAG.test(arg) && UUID.test(args[i + 1] ?? '')) {
281
+ i++
282
+ continue
283
+ }
284
+ kept.push(arg)
285
+ }
286
+ return kept
287
+ }
288
+
254
289
  export function isCommandAvailable(command: string): boolean {
255
290
  return Bun.which(command) !== null
256
291
  }
@@ -7,6 +7,7 @@
7
7
  import type { ModeId } from '@brimveyn/aimux-config'
8
8
 
9
9
  import type { LayoutNode, SplitDirection } from './layout-tree'
10
+ import type { CursorTarget } from './text-cursor'
10
11
  import type {
11
12
  AssistantId,
12
13
  BarSide,
@@ -32,7 +33,7 @@ import type {
32
33
  } from './types'
33
34
 
34
35
  export type ModalAction =
35
- | { type: 'move-modal-cursor'; delta?: number; to?: 'home' | 'end' }
36
+ | { type: 'move-modal-cursor'; delta?: number; to?: CursorTarget }
36
37
  | {
37
38
  type: 'open-new-tab-modal'
38
39
  pendingWorkspace?: PendingWorkspaceLaunch
@@ -106,6 +106,21 @@ function pruneOrphanedTabs(
106
106
  )
107
107
  }
108
108
 
109
+ /**
110
+ * Recover a session id a snapshot lost. Until the client stopped adopting the
111
+ * daemon's tabs wholesale, `sessionId` was wiped on every attach while the
112
+ * daemon's argv echo left the uuid sitting in `command` — so the conversation
113
+ * these tabs own is still recoverable from the very string that broke them.
114
+ *
115
+ * ponytail: a migration, not a mechanism. Delete it once no snapshot in the
116
+ * wild predates the fix.
117
+ */
118
+ function recoverSessionId(command: string): string | undefined {
119
+ return /(?:^|\s)(?:-r|--resume|--session-id)\s+([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(?:\s|$)/i.exec(
120
+ command
121
+ )?.[1]
122
+ }
123
+
109
124
  export function restoreTabsFromProject(
110
125
  snapshot: ProjectSnapshotV1 | undefined,
111
126
  options: RestoreOptions = {}
@@ -130,7 +145,7 @@ export function restoreTabsFromProject(
130
145
  errorMessage: tab.errorMessage,
131
146
  exitCode: tab.exitCode,
132
147
  id: tab.id,
133
- sessionId: tab.sessionId,
148
+ sessionId: tab.sessionId ?? recoverSessionId(tab.command),
134
149
  status: forceDisconnected ? getDisconnectedStatus(tab.status) : tab.status,
135
150
  terminalModes: tab.terminalModes,
136
151
  title: tab.title,
@@ -16,6 +16,7 @@ import {
16
16
  filterSnippets,
17
17
  getNewTabAssistantOptions,
18
18
  } from '../selectors'
19
+ import { applyEdit, moveCursor } from '../text-cursor'
19
20
  import { reduceGitCommitModalState } from './git-commit-modal-state'
20
21
 
21
22
  function emptyModal() {
@@ -106,6 +107,19 @@ function getCreateWorkspaceFieldValue(
106
107
  return field === 'prompt' ? modal.prompt : modal.baseQuery
107
108
  }
108
109
 
110
+ /**
111
+ * The buffer a cursor motion applies to. Every modal but one keeps the field it
112
+ * is editing in `editBuffer` — the multi-field ones (snippet editor, commit
113
+ * message) swap it on Tab — so that is the default. Create-workspace is the
114
+ * exception: its two fields are named, and neither is `editBuffer`.
115
+ */
116
+ function getModalCursorText(state: AppState): string | null {
117
+ if (state.modal.type === 'create-workspace') {
118
+ return getCreateWorkspaceFieldValue(state.modal, state.modal.activeField)
119
+ }
120
+ return state.modal.editBuffer
121
+ }
122
+
109
123
  export function reduceModalState(state: AppState, action: AppAction): AppState | null {
110
124
  // The one modal with a stage machine of its own, in its own file.
111
125
  const gitCommit = reduceGitCommitModalState(state, action)
@@ -604,14 +618,10 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
604
618
  return { ...state, modal: { ...state.modal, selectedIndex: clamped } }
605
619
  }
606
620
  case 'move-modal-cursor': {
607
- if (state.modal.editBuffer === null) return state
608
- const len = state.modal.editBuffer.length
609
- const current = state.modal.cursorPos ?? len
610
- let next = current
611
- if (action.to === 'home') next = 0
612
- else if (action.to === 'end') next = len
613
- else if (typeof action.delta === 'number') next = current + action.delta
614
- next = clampCursor(next, len)
621
+ const text = getModalCursorText(state)
622
+ if (text === null) return state
623
+ const current = clampCursor(state.modal.cursorPos ?? text.length, text.length)
624
+ const next = moveCursor(text, current, action)
615
625
  if (next === current) return state
616
626
  return { ...state, modal: { ...state.modal, cursorPos: next } }
617
627
  }
@@ -656,16 +666,9 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
656
666
  const field = state.modal.activeField
657
667
  const current = getCreateWorkspaceFieldValue(state.modal, field)
658
668
  const at = clampCursor(state.modal.cursorPos ?? current.length, current.length)
659
- let text: string
660
- let pos: number
661
- if (action.char === '\b') {
662
- if (at === 0) return state
663
- text = current.slice(0, at - 1) + current.slice(at)
664
- pos = at - 1
665
- } else {
666
- text = current.slice(0, at) + action.char + current.slice(at)
667
- pos = at + action.char.length
668
- }
669
+ const edit = applyEdit(current, at, action.char)
670
+ if (edit === null) return state
671
+ const { pos, text } = edit
669
672
  if (field === 'prompt') {
670
673
  return {
671
674
  ...state,
@@ -690,16 +693,9 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
690
693
  }
691
694
  const buffer = state.modal.editBuffer
692
695
  const cursor = clampCursor(state.modal.cursorPos ?? buffer.length, buffer.length)
693
- let nextBuffer: string
694
- let nextCursor: number
695
- if (action.char === '\b') {
696
- if (cursor === 0) return state
697
- nextBuffer = buffer.slice(0, cursor - 1) + buffer.slice(cursor)
698
- nextCursor = cursor - 1
699
- } else {
700
- nextBuffer = buffer.slice(0, cursor) + action.char + buffer.slice(cursor)
701
- nextCursor = cursor + action.char.length
702
- }
696
+ const edit = applyEdit(buffer, cursor, action.char)
697
+ if (edit === null) return state
698
+ const { pos: nextCursor, text: nextBuffer } = edit
703
699
  const isNewTabEditing = state.modal.type === 'new-tab' && state.modal.editingCommand !== null
704
700
  const resetIndex =
705
701
  !isNewTabEditing &&
@@ -357,7 +357,18 @@ export function reduceTabState(state: AppState, action: AppAction): AppState | n
357
357
  state.currentProjectId != null && state.currentProjectId !== ''
358
358
  ? state.projects.find((s) => s.id === state.currentProjectId)
359
359
  : undefined
360
- const visibleForWorkspace = filterTabsForActiveWorkspace(action.tabs, currentProject)
360
+ // The daemon rebuilds `command` as `[command, ...args].join(' ')` and has
361
+ // no `sessionId` on the wire at all. Adopting its tabs wholesale bakes this
362
+ // spawn's `--session-id <uuid>` into the string the *next* spawn parses —
363
+ // claude exits on "Session ID … is already in use" and takes the tab with
364
+ // it — and drops the id the resume depends on. Both fields belong to the
365
+ // client, so keep what it already holds for the tabs it knows.
366
+ const ownedById = new Map(state.tabs.map((entry) => [entry.id, entry]))
367
+ const hydratedTabs = action.tabs.map((entry) => {
368
+ const owned = ownedById.get(entry.id)
369
+ return owned ? { ...entry, command: owned.command, sessionId: owned.sessionId } : entry
370
+ })
371
+ const visibleForWorkspace = filterTabsForActiveWorkspace(hydratedTabs, currentProject)
361
372
  const visibleIds = new Set(visibleForWorkspace.map((t) => t.id))
362
373
  const hydratedActiveTabId =
363
374
  action.activeTabId != null &&
@@ -365,7 +376,7 @@ export function reduceTabState(state: AppState, action: AppAction): AppState | n
365
376
  visibleIds.has(action.activeTabId)
366
377
  ? action.activeTabId
367
378
  : (visibleForWorkspace[0]?.id ?? null)
368
- const tabIds = new Set(action.tabs.map((t) => t.id))
379
+ const tabIds = new Set(hydratedTabs.map((t) => t.id))
369
380
 
370
381
  // Restore from new multi-tree format or migrate from legacy single tree
371
382
  const hydratedTrees: Record<string, LayoutNode> = {}
@@ -402,7 +413,7 @@ export function reduceTabState(state: AppState, action: AppAction): AppState | n
402
413
  focusMode: 'navigation',
403
414
  layoutTrees: hydratedTrees,
404
415
  tabGroupMap: hydratedGroupMap,
405
- tabs: normalizeGroupedTabOrder(action.tabs, hydratedTrees, hydratedGroupMap),
416
+ tabs: normalizeGroupedTabOrder(hydratedTabs, hydratedTrees, hydratedGroupMap),
406
417
  },
407
418
  hydratedActiveTabId,
408
419
  { onlyIfMissing: true }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Cursor motion inside a modal's text buffer.
3
+ *
4
+ * Pure string maths, kept out of the reducer so it can be read and tested on its
5
+ * own — the reducer only decides *which* buffer a motion applies to.
6
+ *
7
+ * ponytail: lines are `\n`-delimited, not visually wrapped. A paragraph that the
8
+ * input box soft-wraps over three rows is one line here, so Up/Down step over
9
+ * the whole paragraph. Fixing that means the motion needs the box's width, which
10
+ * lives in the renderer, not the store — worth doing only if the wrapped fields
11
+ * ever get long enough for it to bite.
12
+ */
13
+
14
+ /** Where a motion wants to go. `home`/`end` are line-wise, so they do the right thing in a one-line field too. */
15
+ export type CursorTarget = 'end' | 'home' | 'line-down' | 'line-up' | 'word-left' | 'word-right'
16
+
17
+ function clamp(value: number, max: number): number {
18
+ return Math.max(0, Math.min(max, value))
19
+ }
20
+
21
+ /**
22
+ * The `[start, end)` of the line `at` sits on. `end` is the newline's index (or
23
+ * the buffer's length on the last line), so it is where `End` parks.
24
+ */
25
+ function lineBounds(text: string, at: number): { end: number; start: number } {
26
+ // `lastIndexOf` clamps a negative `fromIndex` to 0, which would find a leading
27
+ // newline and put the start one past it — so column 0 is answered directly.
28
+ const start = at === 0 ? 0 : text.lastIndexOf('\n', at - 1) + 1
29
+ const newline = text.indexOf('\n', at)
30
+ return { end: newline === -1 ? text.length : newline, start }
31
+ }
32
+
33
+ const isWordChar = (ch: string | undefined): boolean => ch !== undefined && /\S/.test(ch)
34
+
35
+ /** Left edge of the word before the cursor: skip the gap, then the word. */
36
+ function wordLeft(text: string, at: number): number {
37
+ let i = at
38
+ while (i > 0 && !isWordChar(text[i - 1])) i--
39
+ while (i > 0 && isWordChar(text[i - 1])) i--
40
+ return i
41
+ }
42
+
43
+ /** Right edge of the word after the cursor, by the same rule. */
44
+ function wordRight(text: string, at: number): number {
45
+ let i = at
46
+ while (i < text.length && !isWordChar(text[i])) i++
47
+ while (i < text.length && isWordChar(text[i])) i++
48
+ return i
49
+ }
50
+
51
+ /** The same column on the line above, or as near to it as that line reaches. */
52
+ function lineUp(text: string, at: number): number {
53
+ const { start } = lineBounds(text, at)
54
+ // Already on the first line: Up parks at its start rather than doing nothing,
55
+ // which is what every editor does with it.
56
+ if (start === 0) return 0
57
+ const previous = lineBounds(text, start - 1)
58
+ return Math.min(previous.start + (at - start), previous.end)
59
+ }
60
+
61
+ /** The same column on the line below. */
62
+ function lineDown(text: string, at: number): number {
63
+ const { end, start } = lineBounds(text, at)
64
+ if (end === text.length) return text.length
65
+ const next = lineBounds(text, end + 1)
66
+ return Math.min(next.start + (at - start), next.end)
67
+ }
68
+
69
+ /** Resolve one motion against `text`, clamped to it. */
70
+ export function moveCursor(
71
+ text: string,
72
+ at: number,
73
+ motion: { delta?: number; to?: CursorTarget }
74
+ ): number {
75
+ const from = clamp(at, text.length)
76
+ if (motion.to === undefined) {
77
+ return clamp(from + (motion.delta ?? 0), text.length)
78
+ }
79
+ switch (motion.to) {
80
+ case 'home':
81
+ return lineBounds(text, from).start
82
+ case 'end':
83
+ return lineBounds(text, from).end
84
+ case 'word-left':
85
+ return wordLeft(text, from)
86
+ case 'word-right':
87
+ return wordRight(text, from)
88
+ case 'line-up':
89
+ return lineUp(text, from)
90
+ case 'line-down':
91
+ return lineDown(text, from)
92
+ default:
93
+ motion.to satisfies never
94
+ return from
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Apply one edit at the cursor. `\b` deletes behind it, `\x7f` in front of it —
100
+ * neither can arrive as typed text, which is why they double as the two commands.
101
+ */
102
+ export function applyEdit(
103
+ text: string,
104
+ at: number,
105
+ char: string
106
+ ): { pos: number; text: string } | null {
107
+ const from = clamp(at, text.length)
108
+ if (char === '\b') {
109
+ if (from === 0) return null
110
+ return { pos: from - 1, text: text.slice(0, from - 1) + text.slice(from) }
111
+ }
112
+ if (char === '\x7f') {
113
+ if (from === text.length) return null
114
+ return { pos: from, text: text.slice(0, from) + text.slice(from + 1) }
115
+ }
116
+ return { pos: from + char.length, text: text.slice(0, from) + char + text.slice(from) }
117
+ }
@@ -2,6 +2,7 @@ import { useKeyboard } from '@opentui/react'
2
2
 
3
3
  import { ModalShell } from './components/modals/shared/modal-shell'
4
4
  import { ListItem } from './components/primitives/list-item'
5
+ import { useSelectionInk } from './selection-ink'
5
6
  import { uiTokens } from './ui-tokens'
6
7
 
7
8
  interface BreakingUpdateScreenProps {
@@ -9,6 +10,7 @@ interface BreakingUpdateScreenProps {
9
10
  }
10
11
 
11
12
  export function BreakingUpdateScreen({ onConfirm }: BreakingUpdateScreenProps) {
13
+ const ink = useSelectionInk()
12
14
  useKeyboard((key) => {
13
15
  if (key.name === 'return') {
14
16
  key.preventDefault()
@@ -24,7 +26,11 @@ export function BreakingUpdateScreen({ onConfirm }: BreakingUpdateScreenProps) {
24
26
  <text>The terminal manager must be restarted.</text>
25
27
  <text>You will lose your current terminal states.</text>
26
28
  </box>
27
- <ListItem active direction="row" title={<text>OK — Restart terminal manager</text>} />
29
+ <ListItem
30
+ active
31
+ direction="row"
32
+ title={<text fg={ink}>OK — Restart terminal manager</text>}
33
+ />
28
34
  </box>
29
35
  </ModalShell>
30
36
  )
@@ -291,9 +291,14 @@ export const PrChecksPanel = memo(function PrChecksPanel({
291
291
  No checks
292
292
  </text>
293
293
  ) : (
294
- checks.map((check) => (
294
+ // GitHub can list the same workflow/name twice (a job that ran on
295
+ // both `push` and `pull_request`), so the pair is not a unique key
296
+ // and the duplicate crashes the reconciler. The rows hold no state
297
+ // and the list is the API's own order, so the index is the key.
298
+ checks.map((check, i) => (
295
299
  <CheckRow
296
- key={`${check.workflow}/${check.name}`}
300
+ // oxlint-disable-next-line no-array-index-key
301
+ key={`${i}:${check.workflow}/${check.name}`}
297
302
  bg={bg}
298
303
  check={check}
299
304
  showWorkflow={innerWidth >= WORKFLOW_MIN_WIDTH}
@@ -53,11 +53,9 @@ export function BarFooter({ contentWidth }: { contentWidth: number }) {
53
53
 
54
54
  return (
55
55
  <box flexDirection="column" flexShrink={0}>
56
- <box flexShrink={0}>
57
- <text fg={t.border} selectable={false} wrapMode="none">
58
- {'─'.repeat(Math.max(1, contentWidth))}
59
- </text>
60
- </box>
56
+ {/* A blank row, not a rule: the footer is set off from the last widget by
57
+ the gap alone. */}
58
+ <box height={1} flexShrink={0} />
61
59
  {/* Each entry is its own <text>, with a spacer box between them: one string
62
60
  holding both would make the whole footer a single click target. */}
63
61
  <box flexDirection="row" flexShrink={0} paddingLeft={1} paddingRight={1}>