dsh-code 1.0.2 → 1.0.4

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 (53) hide show
  1. package/README.en.md +21 -13
  2. package/README.md +285 -271
  3. package/bin/deepseek.mjs +26 -3
  4. package/lib/index.mjs +2962 -1560
  5. package/lib/types/app.d.ts +13 -2
  6. package/lib/types/commands.d.ts +13 -0
  7. package/lib/types/editor-keys.d.ts +105 -0
  8. package/lib/types/git-workflow.d.ts +6 -2
  9. package/lib/types/index.d.ts +28 -0
  10. package/lib/types/input-split.d.ts +54 -0
  11. package/lib/types/kernel-panels.d.ts +3 -1
  12. package/lib/types/keyboard.d.ts +8 -0
  13. package/lib/types/model-capabilities.d.ts +82 -0
  14. package/lib/types/provider-settings.d.ts +84 -0
  15. package/lib/types/render/lines.d.ts +25 -0
  16. package/lib/types/render/markdown.d.ts +1 -1
  17. package/lib/types/render/projection.d.ts +22 -2
  18. package/lib/types/render/status.d.ts +22 -15
  19. package/lib/types/render/text.d.ts +15 -9
  20. package/lib/types/render/width.d.ts +29 -0
  21. package/lib/types/session-directory.d.ts +27 -0
  22. package/lib/types/settings-file.d.ts +33 -0
  23. package/lib/types/skills.d.ts +1 -1
  24. package/lib/types/store.d.ts +10 -0
  25. package/lib/types/subagents.d.ts +13 -3
  26. package/package.json +159 -159
  27. package/src/app.ts +4514 -3892
  28. package/src/approval.ts +8 -3
  29. package/src/authorization-panel.ts +2 -4
  30. package/src/commands.ts +27 -3
  31. package/src/editor-keys.ts +371 -0
  32. package/src/git-workflow.ts +10 -6
  33. package/src/index.ts +1752 -1523
  34. package/src/input-split.ts +191 -0
  35. package/src/internals.ts +26 -8
  36. package/src/kernel-panels.ts +26 -10
  37. package/src/keyboard.ts +123 -88
  38. package/src/mentions.ts +42 -9
  39. package/src/model-capabilities.ts +318 -0
  40. package/src/provider-settings.ts +220 -0
  41. package/src/questions.ts +20 -0
  42. package/src/render/lines.ts +415 -356
  43. package/src/render/markdown.ts +18 -19
  44. package/src/render/projection.ts +162 -52
  45. package/src/render/status.ts +76 -71
  46. package/src/render/text.ts +158 -150
  47. package/src/render/width.ts +189 -0
  48. package/src/session-directory.ts +56 -0
  49. package/src/settings-file.ts +56 -0
  50. package/src/skills.ts +19 -6
  51. package/src/store.ts +26 -7
  52. package/src/subagents.ts +39 -6
  53. package/src/theme-panel.ts +79 -72
@@ -0,0 +1,191 @@
1
+ /**
2
+ * Terminal input arrives as byte chunks, and one chunk can carry several
3
+ * keypresses: a fast space-then-enter, a bridged stdin that batches reads, a
4
+ * middle-click paste. Ink parses each chunk as exactly one keypress —
5
+ * `parseKeypress(' \r')` matches neither member, so both keys silently
6
+ * vanish (a multi-select question answered with an empty set). The splitter
7
+ * below cuts every chunk into the individual keypress units Ink's parser
8
+ * expects, keeping escape sequences and bracketed-paste blocks intact, and
9
+ * the stdin proxy feeds the split stream to the Ink mount.
10
+ *
11
+ * @module @deepseek-ai/dsh-tui/input-split
12
+ */
13
+
14
+ import { PassThrough } from 'node:stream'
15
+ import { PASTE_BRACKET_TIMEOUT_MS } from './keyboard.ts'
16
+
17
+ /** Bracketed-paste wrapper bytes; the whole block travels as one unit. */
18
+ const PASTE_START = '\x1b[200~'
19
+ const PASTE_END = '\x1b[201~'
20
+
21
+ /** One keypress cut from the input stream. */
22
+ export interface KeypressSplitter {
23
+ /** Feed one chunk; returns every keypress unit this chunk completed. */
24
+ push(chunk: string): string[]
25
+ /** Whether an unterminated bracketed-paste block is currently held. */
26
+ openPaste(): boolean
27
+ /**
28
+ * Last-resort escape hatch for a paste whose end marker never arrived:
29
+ * drop the start marker and emit the held bytes as plain keypress units so
30
+ * nothing (Esc and Ctrl+C included) stays hostage. Inert when no paste is
31
+ * open.
32
+ */
33
+ releaseStalePaste(): string[]
34
+ }
35
+
36
+ /** Final byte of a CSI sequence (\x40-\x7e per ECMA-48). */
37
+ const isCsiFinal = (char: string): boolean => char >= '@' && char <= '~'
38
+
39
+ /**
40
+ * Build a stateful chunk splitter. A partial unit at the end of one chunk
41
+ * (a cut CSI sequence, an open paste block) waits in the buffer for the
42
+ * rest. A chunk-trailing lone ESC emits as the Escape key right away:
43
+ * terminals send Escape as its own chunk, and holding it hostage for a
44
+ * sequence that may never continue would break every Esc cancel.
45
+ */
46
+ export function createKeypressSplitter(): KeypressSplitter {
47
+ let buffer = ''
48
+ const push = (chunk: string): string[] => {
49
+ buffer += chunk
50
+ const units: string[] = []
51
+ while (buffer !== '') {
52
+ // A bracketed paste block is display text, not keypresses: keep the
53
+ // whole wrapper plus its payload as the single unit the composer
54
+ // expects, even when the payload contains ESC-looking bytes.
55
+ if (buffer.startsWith(PASTE_START)) {
56
+ const end = buffer.indexOf(PASTE_END, PASTE_START.length)
57
+ if (end < 0) break
58
+ const stop = end + PASTE_END.length
59
+ units.push(buffer.slice(0, stop))
60
+ buffer = buffer.slice(stop)
61
+ continue
62
+ }
63
+ const head = buffer[0]!
64
+ if (head !== '\x1b') {
65
+ // Plain bytes key one at a time; a surrogate pair is one grapheme
66
+ // and must not split into two lone surrogates.
67
+ const pair = head >= '\uD800' && head <= '\uDBFF' && buffer[1] !== undefined
68
+ const take = pair ? 2 : 1
69
+ units.push(buffer.slice(0, take))
70
+ buffer = buffer.slice(take)
71
+ continue
72
+ }
73
+ // CSI (\x1b[…final) and SS3 (\x1bO<char): hold until complete.
74
+ if (buffer[1] === '[') {
75
+ let end = -1
76
+ for (let at = 2; at < buffer.length; at += 1) {
77
+ if (isCsiFinal(buffer[at]!)) {
78
+ end = at
79
+ break
80
+ }
81
+ }
82
+ if (end < 0) break
83
+ units.push(buffer.slice(0, end + 1))
84
+ buffer = buffer.slice(end + 1)
85
+ continue
86
+ }
87
+ if (buffer[1] === 'O') {
88
+ if (buffer[2] === undefined) break
89
+ units.push(buffer.slice(0, 3))
90
+ buffer = buffer.slice(3)
91
+ continue
92
+ }
93
+ if (buffer[1] === undefined) {
94
+ // Lone ESC: Escape key (see the tradeoff above).
95
+ units.push(buffer)
96
+ buffer = ''
97
+ continue
98
+ }
99
+ // Alt+key: ESC glued to one more byte travels as one unit.
100
+ units.push(buffer.slice(0, 2))
101
+ buffer = buffer.slice(2)
102
+ }
103
+ return units
104
+ }
105
+ return {
106
+ push,
107
+ openPaste(): boolean {
108
+ return buffer.startsWith(PASTE_START)
109
+ },
110
+ releaseStalePaste(): string[] {
111
+ if (!buffer.startsWith(PASTE_START)) return []
112
+ // Strip the unterminated start marker, then re-run the unit loop: the
113
+ // held bytes flow as ordinary keypresses under all the normal rules.
114
+ buffer = buffer.slice(PASTE_START.length)
115
+ return push('')
116
+ },
117
+ }
118
+ }
119
+
120
+
121
+ /** The stdin-shaped stream the Ink mount renders through. */
122
+ export interface TuiStdin extends PassThrough {
123
+ /** Mirrors the real stdin so Ink's raw-mode gate passes. */
124
+ isTTY: boolean
125
+ /** Forwarded to the real stdin; Ink toggles it around focus. */
126
+ setRawMode(value: boolean): unknown
127
+ ref(): void
128
+ unref(): void
129
+ }
130
+
131
+ /**
132
+ * Wrap one real stdin in the splitting proxy: keypress units flow into a
133
+ * PassThrough Ink reads, while raw-mode/ref calls forward to the source.
134
+ * @param source - the process (or harness) input stream in raw mode.
135
+ * @returns the proxy stream plus a dispose that detaches the tap.
136
+ */
137
+ export function createSplitStdin(source: NodeJS.ReadStream): { stdin: TuiStdin; dispose(): void } {
138
+ // Object mode matters: a plain stream's read() without a size drains the
139
+ // whole buffer as one chunk, which would re-coalesce the units this module
140
+ // exists to separate. In object mode every pushed unit reads back alone.
141
+ const stream = new PassThrough({ objectMode: true }) as TuiStdin
142
+ const splitter = createKeypressSplitter()
143
+ let stalePasteTimer: ReturnType<typeof setTimeout> | undefined
144
+ const disarmStalePasteTimer = (): void => {
145
+ if (stalePasteTimer === undefined) return
146
+ clearTimeout(stalePasteTimer)
147
+ stalePasteTimer = undefined
148
+ }
149
+ // A terminal that drops the end marker must not swallow every following
150
+ // keypress forever: past the shared paste window the held block is released
151
+ // as plain text, keeping Esc/Ctrl+C reachable. The App-level paste flag has
152
+ // its own reset net with the same window, but it can only see markers that
153
+ // reach Ink — this one guards the bytes that never do.
154
+ const armStalePasteTimer = (): void => {
155
+ if (stalePasteTimer !== undefined || !splitter.openPaste()) return
156
+ stalePasteTimer = setTimeout(() => {
157
+ stalePasteTimer = undefined
158
+ for (const unit of splitter.releaseStalePaste()) stream.write(unit)
159
+ armStalePasteTimer()
160
+ }, PASTE_BRACKET_TIMEOUT_MS)
161
+ stalePasteTimer.unref?.()
162
+ }
163
+ const onChunk = (chunk: string): void => {
164
+ for (const unit of splitter.push(String(chunk))) stream.write(unit)
165
+ if (splitter.openPaste()) armStalePasteTimer()
166
+ else disarmStalePasteTimer()
167
+ }
168
+ const proxy = Object.assign(stream, {
169
+ isTTY: source.isTTY === true,
170
+ setRawMode(value: boolean): TuiStdin {
171
+ source.setRawMode?.(value)
172
+ return stream
173
+ },
174
+ ref(): void {
175
+ source.ref?.()
176
+ },
177
+ unref(): void {
178
+ source.unref?.()
179
+ },
180
+ })
181
+ source.setEncoding('utf8')
182
+ source.on('data', onChunk)
183
+ return {
184
+ stdin: proxy,
185
+ dispose(): void {
186
+ disarmStalePasteTimer()
187
+ source.removeListener('data', onChunk)
188
+ source.pause()
189
+ },
190
+ }
191
+ }
package/src/internals.ts CHANGED
@@ -18,6 +18,7 @@ import {
18
18
  isVsCodeTerminalEnv,
19
19
  shouldEnableKeyboardEnhancement,
20
20
  } from './keyboard.ts'
21
+ import { createSplitStdin } from './input-split.ts'
21
22
 
22
23
  /** A mounted terminal app instance; the runner owns unmount ordering. */
23
24
  export interface TuiMount {
@@ -52,19 +53,36 @@ export const internals: {
52
53
  // draft, quit). Ink's default `exitOnCtrlC: true` would intercept the
53
54
  // normalized control byte first, unmount only its renderer, and leave the
54
55
  // Harness runner plus the pushed keyboard protocol alive.
55
- const instance = render(element, { exitOnCtrlC: false })
56
+ // stdin travels through the keypress splitter: Ink parses one chunk as
57
+ // one keypress, so a coalesced space-then-enter would drop both keys.
58
+ const tuiStdin = createSplitStdin(process.stdin)
59
+ // Ink only touches isTTY/setRawMode/ref/read on stdin; the object-mode
60
+ // proxy satisfies that contract without the full ReadStream surface.
61
+ const instance = render(element, {
62
+ exitOnCtrlC: false,
63
+ stdin: tuiStdin.stdin as unknown as NodeJS.ReadStream,
64
+ stdout: process.stdout,
65
+ })
56
66
  return {
57
67
  rerender(element: ReactElement): void {
58
68
  instance.rerender(element)
59
69
  },
60
70
  unmount(): void {
61
- instance.unmount()
62
- // Pop only a stack this mount pushed, then disable bracketed paste.
63
- process.stdout.write(
64
- (keyboardEnhanced ? KEYBOARD_ENHANCE_DISABLE : '')
65
- + BRACKETED_PASTE_DISABLE
66
- + (focusReporting ? TERMINAL_FOCUS_REPORT_DISABLE : ''),
67
- )
71
+ // The cleanup below must run even when Ink's unmount throws (a
72
+ // render-teardown failure): a stdin tap or pushed terminal-protocol
73
+ // stack outliving the app wedges the terminal for whatever runs
74
+ // next, and a stray exception here must not skip the exit sequence.
75
+ try {
76
+ instance.unmount()
77
+ } finally {
78
+ tuiStdin.dispose()
79
+ // Pop only a stack this mount pushed, then disable bracketed paste.
80
+ process.stdout.write(
81
+ (keyboardEnhanced ? KEYBOARD_ENHANCE_DISABLE : '')
82
+ + BRACKETED_PASTE_DISABLE
83
+ + (focusReporting ? TERMINAL_FOCUS_REPORT_DISABLE : ''),
84
+ )
85
+ }
68
86
  },
69
87
  }
70
88
  },
@@ -47,9 +47,19 @@ function searchLine(searching: boolean | undefined, query: string): string {
47
47
  function ListFrame(props: ListFrameProps): ReactElement {
48
48
  const stdout = useStdout().stdout
49
49
  const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
50
- if (viewport.maxHeight === 0) return createElement(Box, { display: 'none' })
51
- if (viewport.compact) {
52
- return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(singleLineText(`${props.title} · esc close`), viewport.contentColumns))
50
+ if (viewport.maxHeight === 0 || viewport.compact) {
51
+ // One visible row instead of a hidden panel: the current selection (or
52
+ // load state) is shown so Enter/arrows are never blind keys acting on
53
+ // invisible state. The selection leads and `esc close` follows it, so a
54
+ // narrow terminal truncates the panel title — never the actionable facts.
55
+ const body = props.loading
56
+ ? `${props.title} · loading…`
57
+ : props.error !== undefined
58
+ ? `${props.title} · load failed`
59
+ : props.rows.length === 0
60
+ ? `${props.title} · no matching entries`
61
+ : `❯ ${singleLineText(props.rows[props.cursor]?.text ?? '')}`
62
+ return createElement(Text, { wrap: 'truncate-end' }, truncateColumns(singleLineText(`${body} · esc close`), viewport.contentColumns))
53
63
  }
54
64
  const stateRows = props.loading
55
65
  ? [{ key: 'loading', text: ' loading…' }]
@@ -117,7 +127,10 @@ export function ModePanel({ current, load, select, close }: {
117
127
  if (input === 'r' && query === '') return refresh()
118
128
  if (key.upArrow) return setCursor(value => visible.length === 0 ? 0 : (value + visible.length - 1) % visible.length)
119
129
  if (key.downArrow) return setCursor(value => visible.length === 0 ? 0 : (value + 1) % visible.length)
120
- if (key.return && visible[cursor]?.broken === undefined) return select(visible[cursor]!.id)
130
+ // Empty/loading/filtered-out lists have no row at the cursor: a bare
131
+ // `?.broken === undefined` check passes on undefined and crashes the
132
+ // process on the `!.id` access (PermissionPanel guards this correctly).
133
+ if (key.return && visible[cursor] !== undefined && visible[cursor]!.broken === undefined) return select(visible[cursor]!.id)
121
134
  const next = editQuery(query, input, key)
122
135
  if (next !== undefined) { setQuery(next); setCursor(0) }
123
136
  })
@@ -494,10 +507,10 @@ export function HistoryPanel({ entries, fill, close }: {
494
507
  })
495
508
  const stdout = useStdout().stdout
496
509
  const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
497
- if (viewport.compact) {
498
- return createElement(Text, { wrap: 'truncate-end' }, truncateColumns('/history · esc close', viewport.contentColumns))
510
+ if (viewport.maxHeight === 0 || viewport.compact) {
511
+ const picked = matches[cursor]
512
+ return createElement(Text, { wrap: 'truncate-end' }, truncateColumns('/history · ' + (picked === undefined ? 'no matching prompts' : singleLineText(picked)) + ' · esc close', viewport.contentColumns))
499
513
  }
500
- if (viewport.maxHeight === 0) return createElement(Box, { display: 'none' })
501
514
  const bodyRows = Math.max(1, viewport.bodyRows - 1)
502
515
  const offset = revealRow(0, cursor, matches.length, bodyRows)
503
516
  const visible = matches.slice(offset, offset + bodyRows)
@@ -586,10 +599,9 @@ export function StatuslinePanel({ enabled, change, close }: {
586
599
  })
587
600
  const stdout = useStdout().stdout
588
601
  const viewport = panelViewport(stdout?.columns ?? 80, stdout?.rows ?? 30)
589
- if (viewport.compact) {
602
+ if (viewport.maxHeight === 0 || viewport.compact) {
590
603
  return createElement(Text, { wrap: 'truncate-end' }, truncateColumns('/statusline · esc close', viewport.contentColumns))
591
604
  }
592
- if (viewport.maxHeight === 0) return createElement(Box, { display: 'none' })
593
605
  const bodyRows = Math.max(1, viewport.bodyRows - 1)
594
606
  const offset = revealRow(0, cursor, order.length, bodyRows)
595
607
  const visible = order.slice(offset, offset + bodyRows)
@@ -630,7 +642,7 @@ export function StatuslinePanel({ enabled, change, close }: {
630
642
  * instead of a bare failure notice. Enter applies one level; Esc returns to
631
643
  * the model list without applying.
632
644
  */
633
- export function EffortPanel({ row, current, select, back }: {
645
+ export function EffortPanel({ row, current, select, back, onExit }: {
634
646
  /** The model row whose advertised levels this stage lists. */
635
647
  row: ModelRow
636
648
  /** Effective effort currently in force ('' when none), for the ● mark. */
@@ -639,6 +651,8 @@ export function EffortPanel({ row, current, select, back }: {
639
651
  select(effortId: string): void
640
652
  /** Return to the model list without applying. */
641
653
  back(): void
654
+ /** Leave the whole /model flow (Ctrl+C). */
655
+ onExit(): void
642
656
  }): ReactElement {
643
657
  const advertised = row.reasoning?.efforts ?? []
644
658
  const empty = row.reasoning === undefined || advertised.length === 0
@@ -666,6 +680,7 @@ export function EffortPanel({ row, current, select, back }: {
666
680
  }, [rows.length, cursor])
667
681
  useInput((input, key) => {
668
682
  if (key.escape || input === 'q') return back()
683
+ if (key.ctrl && input === 'c') return onExit()
669
684
  if (empty) return
670
685
  if (input === 'g') {
671
686
  setCursor(0)
@@ -894,6 +909,7 @@ export function SubagentPanel({ current, load, pick, inherit, close }: {
894
909
  current: current === '' ? undefined : current.split('@')[1],
895
910
  select: effortId => pick(effortFor, effortId),
896
911
  back: () => setEffortFor(undefined),
912
+ onExit: close,
897
913
  })
898
914
  }
899
915
  return createElement(ListFrame, {
package/src/keyboard.ts CHANGED
@@ -1,25 +1,25 @@
1
- /**
2
- * Keyboard enhancement protocol (Codex `keyboard_modes` parity) and the
3
- * kitty CSI-u normalization layer.
4
- *
1
+ /**
2
+ * Keyboard enhancement protocol (Codex `keyboard_modes` parity) and the
3
+ * kitty CSI-u normalization layer.
4
+ *
5
5
  * The TUI pushes the kitty keyboard protocol with DISAMBIGUATE_ESCAPE_CODES
6
6
  * and REPORT_ALTERNATE_KEYS (flags 1|4 = `\x1b[>5u`). Event types are
7
7
  * deliberately NOT requested: Ink 5's parser cannot decode the
8
8
  * `:event-type` suffix, and repeat/release reporting buys this surface
9
9
  * nothing.
10
- *
11
- * Ink 5 also cannot parse most CSI-u forms at all — they fall through its
12
- * regex as unnamed sequences and get INSERTED AS DRAFT TEXT. The composer's
13
- * stdin read patch therefore rewrites every CSI-u form it can decode back
14
- * to the legacy byte or canonical sequence the existing key handling
10
+ *
11
+ * Ink 5 also cannot parse most CSI-u forms at all — they fall through its
12
+ * regex as unnamed sequences and get INSERTED AS DRAFT TEXT. The composer's
13
+ * stdin read patch therefore rewrites every CSI-u form it can decode back
14
+ * to the legacy byte or canonical sequence the existing key handling
15
15
  * already understands, before Ink ever parses the chunk.
16
16
  * @module @deepseek-ai/dsh-code/keyboard
17
17
  */
18
18
 
19
19
  /** Push keyboard enhancement (modifyOtherKeys off, kitty flags 1|4). */
20
20
  export const KEYBOARD_ENHANCE_ENABLE = '\x1b[>4;0m\x1b[>5u'
21
-
22
- /** Pop the enhancement stack and reset modifyOtherKeys (exit path). */
21
+
22
+ /** Pop the enhancement stack and reset modifyOtherKeys (exit path). */
23
23
  export const KEYBOARD_ENHANCE_DISABLE = '\x1b[<u\x1b[>4;0m'
24
24
 
25
25
  /** Explicit environment overrides for terminal keyboard enhancement. */
@@ -47,7 +47,7 @@ export function shouldEnableKeyboardEnhancement(env: NodeJS.ProcessEnv = process
47
47
  if (explicitEnable !== undefined) return explicitEnable
48
48
  return !isVsCodeTerminalEnv(env)
49
49
  }
50
-
50
+
51
51
  /** Enable bracketed paste reporting. */
52
52
  export const BRACKETED_PASTE_ENABLE = '\x1b[?2004h'
53
53
 
@@ -70,16 +70,25 @@ export function stripTerminalFocusEvents(chunk: string, onFocus: (focused: boole
70
70
  return ''
71
71
  })
72
72
  }
73
-
74
- /** Bracketed paste markers as Ink delivers them (it strips the leading ESC). */
75
- export const PASTE_START_MARKER = '[200~'
76
- export const PASTE_END_MARKER = '[201~'
77
-
78
- /**
79
- * Remove bracketed paste markers from one input chunk. Panel drafts accept raw
80
- * `input` text, where an unhandled paste would otherwise persist the literal
81
- * "[200~"/"[201~" markers Ink leaves after stripping the ESC byte.
82
- */
73
+
74
+ /** Bracketed paste markers as Ink delivers them (it strips the leading ESC). */
75
+ export const PASTE_START_MARKER = '[200~'
76
+ export const PASTE_END_MARKER = '[201~'
77
+
78
+ /**
79
+ * How long an unterminated bracketed-paste block may hold buffered bytes
80
+ * before the input splitter strips its start marker and releases them: a
81
+ * terminal that loses the end marker must never take the whole keyboard
82
+ * hostage (Esc/Ctrl+C included). Shared by the splitter and the composer's
83
+ * lost-paste safety net so both use one window.
84
+ */
85
+ export const PASTE_BRACKET_TIMEOUT_MS = 1_000
86
+
87
+ /**
88
+ * Remove bracketed paste markers from one input chunk. Panel drafts accept raw
89
+ * `input` text, where an unhandled paste would otherwise persist the literal
90
+ * "[200~"/"[201~" markers Ink leaves after stripping the ESC byte.
91
+ */
83
92
  export function stripPasteMarkers(text: string): string {
84
93
  return text
85
94
  .replaceAll(`\x1b${PASTE_START_MARKER}`, '')
@@ -87,22 +96,37 @@ export function stripPasteMarkers(text: string): string {
87
96
  .replaceAll(PASTE_START_MARKER, '')
88
97
  .replaceAll(PASTE_END_MARKER, '')
89
98
  }
90
-
91
- /** One decoded CSI-u keypress: key code, 1-based modifier param, alternate code. */
92
- interface CsiUKey {
93
- code: number
94
- modifiers: number
95
- alternate?: number
96
- }
97
-
98
- /** Match one CSI-u sequence (code, optional ;modifiers, then :event or ;alternate). */
99
- const CSI_U_SOURCE = '\x1b\\[(\\d+)(?:;(\\d+))?(?:[:;](\\d+))?u'
99
+
100
+ /** One decoded CSI-u keypress: key code, 1-based modifier param, alternate code. */
101
+ interface CsiUKey {
102
+ code: number
103
+ modifiers: number
104
+ alternate?: number
105
+ }
106
+
107
+ /** Match one CSI-u sequence (code, optional ;modifiers, then :event or ;alternate). */
108
+ const CSI_U_SOURCE = '\x1b\\[(\\d+)(?:;(\\d+))?(?:(:|;)(\\d+))?u'
109
+
110
+ /** Kitty private-use keycodes for the numeric keypad and keypad Enter. */
111
+ const KITTY_KEYPAD_CODES: Readonly<Record<number, string>> = {
112
+ 57399: '0',
113
+ 57400: '1',
114
+ 57401: '2',
115
+ 57402: '3',
116
+ 57403: '4',
117
+ 57404: '5',
118
+ 57405: '6',
119
+ 57406: '7',
120
+ 57407: '8',
121
+ 57408: '9',
122
+ 57414: '\r',
123
+ }
100
124
 
101
125
  /** Legacy equivalent for one decoded CSI-u key, or undefined to pass through. */
102
126
  function legacyForKey(key: CsiUKey): string | undefined {
103
- const bits = Math.max(0, key.modifiers - 1)
104
- const shift = (bits & 1) !== 0
105
- const alt = (bits & 2) !== 0
127
+ const bits = Math.max(0, key.modifiers - 1)
128
+ const shift = (bits & 1) !== 0
129
+ const alt = (bits & 2) !== 0
106
130
  const ctrl = (bits & 4) !== 0
107
131
  if (key.code === 13) {
108
132
  // Modified Enter has no dedicated composer behavior. Preserve the legacy
@@ -110,62 +134,73 @@ function legacyForKey(key: CsiUKey): string | undefined {
110
134
  if (ctrl) return '\n'
111
135
  if (alt) return '\x1b\r'
112
136
  return '\r'
113
- }
114
- if (key.code === 27) return '\x1b'
115
- if (key.code === 9) return shift ? '\x1b[Z' : '\t'
116
- if (key.code === 127) return alt || ctrl ? '\x1b\x7f' : '\x7f'
117
- // Kitty disambiguate mode reports the six legacy functional keys as CSI u
118
- // codes 1-6 (Home, Insert, Delete, End, PageUp, PageDown). Ink 5 cannot
119
- // parse these forms and would insert literal "[3u" text into the draft, so
120
- // rewrite them to the legacy sequences the input layer already annotates.
121
- // The modifier parameter passes through: kitty and xterm share the same
122
- // 1+bit-field encoding (shift 2, alt 3, ctrl 5, ...). Lock-key bits are
123
- // dropped because the legacy sequences cannot express them.
124
- if (key.code >= 1 && key.code <= 6) {
125
- const mask = (shift ? 1 : 0) + (alt ? 2 : 0) + (ctrl ? 4 : 0)
126
- const mods = mask === 0 ? '' : `;${mask + 1}`
127
- if (key.code === 1) return mods === '' ? '\x1b[H' : `\x1b[1${mods}H`
128
- if (key.code === 4) return mods === '' ? '\x1b[F' : `\x1b[1${mods}F`
129
- return `\x1b[${key.code}${mods}~`
130
- }
131
- if (key.code >= 97 && key.code <= 122) {
132
- const letter = String.fromCodePoint(key.code)
133
- if (ctrl) return String.fromCodePoint(key.code - 96)
134
- if (alt) return '\x1b' + letter
135
- if (shift) return String.fromCodePoint(key.alternate ?? key.code - 32)
136
- return letter
137
- }
138
- if (key.code >= 65 && key.code <= 90) {
139
- if (ctrl) return String.fromCodePoint(key.code + 32 - 96)
140
- if (alt) return '\x1b' + String.fromCodePoint(key.code + 32)
141
- return String.fromCodePoint(key.code)
142
- }
143
- if (key.code >= 32 && key.code <= 126 && key.alternate !== undefined) {
144
- const base = key.alternate >= 97 && key.alternate <= 122 ? key.alternate : key.code
145
- if (ctrl && base - 96 >= 1 && base - 96 <= 26) return String.fromCodePoint(base - 96)
146
- if (alt) return '\x1b' + String.fromCodePoint(key.alternate)
147
- return String.fromCodePoint(key.alternate)
148
- }
149
- return undefined
150
- }
151
-
152
- /**
153
- * Rewrite every decodable kitty CSI-u sequence in one stdin chunk to the
154
- * legacy form the input layer already handles. Undecodable or non-key
155
- * sequences pass through untouched, so terminals without the protocol are
156
- * unaffected.
157
- */
137
+ }
138
+ if (key.code === 27) return '\x1b'
139
+ if (key.code === 9) return shift ? '\x1b[Z' : '\t'
140
+ if (key.code === 127) return alt || ctrl ? '\x1b\x7f' : '\x7f'
141
+ const keypad = KITTY_KEYPAD_CODES[key.code]
142
+ if (keypad !== undefined) {
143
+ if (alt) return '\x1b' + keypad
144
+ return keypad
145
+ }
146
+ // Kitty disambiguate mode reports the six legacy functional keys as CSI u
147
+ // codes 1-6 (Home, Insert, Delete, End, PageUp, PageDown). Ink 5 cannot
148
+ // parse these forms and would insert literal "[3u" text into the draft, so
149
+ // rewrite them to the legacy sequences the input layer already annotates.
150
+ // The modifier parameter passes through: kitty and xterm share the same
151
+ // 1+bit-field encoding (shift 2, alt 3, ctrl 5, ...). Lock-key bits are
152
+ // dropped because the legacy sequences cannot express them.
153
+ if (key.code >= 1 && key.code <= 6) {
154
+ const mask = (shift ? 1 : 0) + (alt ? 2 : 0) + (ctrl ? 4 : 0)
155
+ const mods = mask === 0 ? '' : `;${mask + 1}`
156
+ if (key.code === 1) return mods === '' ? '\x1b[H' : `\x1b[1${mods}H`
157
+ if (key.code === 4) return mods === '' ? '\x1b[F' : `\x1b[1${mods}F`
158
+ return `\x1b[${key.code}${mods}~`
159
+ }
160
+ if (key.code >= 97 && key.code <= 122) {
161
+ const letter = String.fromCodePoint(key.code)
162
+ if (ctrl) return String.fromCodePoint(key.code - 96)
163
+ if (alt) return '\x1b' + letter
164
+ if (shift) return String.fromCodePoint(key.alternate ?? key.code - 32)
165
+ return letter
166
+ }
167
+ if (key.code >= 65 && key.code <= 90) {
168
+ if (ctrl) return String.fromCodePoint(key.code + 32 - 96)
169
+ if (alt) return '\x1b' + String.fromCodePoint(key.code + 32)
170
+ return String.fromCodePoint(key.code)
171
+ }
172
+ if (key.code >= 32 && key.code <= 126) {
173
+ // Kitty reports ordinary printable keys as CSI-u with no alternate code
174
+ // (for example space is `ESC[32u` and `1` is `ESC[49u`). Keep the
175
+ // alternate form when present, but never leave a plain printable key as
176
+ // an unknown escape sequence for Ink to swallow.
177
+ const base = key.alternate ?? key.code
178
+ if (ctrl && base - 96 >= 1 && base - 96 <= 26) return String.fromCodePoint(base - 96)
179
+ if (alt) return '\x1b' + String.fromCodePoint(base)
180
+ return String.fromCodePoint(base)
181
+ }
182
+ return undefined
183
+ }
184
+
185
+ /**
186
+ * Rewrite every decodable kitty CSI-u sequence in one stdin chunk to the
187
+ * legacy form the input layer already handles. Undecodable or non-key
188
+ * sequences pass through untouched, so terminals without the protocol are
189
+ * unaffected.
190
+ */
158
191
  export function normalizeKeyboardChunk(chunk: string): string {
159
192
  if (!chunk.includes('\x1b[') || !chunk.includes('u')) return chunk
160
193
  const pattern = new RegExp(CSI_U_SOURCE, 'g')
161
- return chunk.replace(pattern, (whole, code: string, mods?: string, third?: string) => {
162
- const legacy = legacyForKey({
163
- code: Number.parseInt(code, 10),
164
- modifiers: mods === undefined || mods === '' ? 1 : Math.max(1, Number.parseInt(mods, 10)),
165
- alternate: third !== undefined && third !== '' ? Number.parseInt(third, 10) : undefined,
166
- })
167
- return legacy ?? whole
168
- })
194
+ return chunk.replace(pattern, (whole, code: string, mods?: string, separator?: ':' | ';', third?: string) => {
195
+ const legacy = legacyForKey({
196
+ code: Number.parseInt(code, 10),
197
+ modifiers: mods === undefined || mods === '' ? 1 : Math.max(1, Number.parseInt(mods, 10)),
198
+ // A colon introduces Kitty's optional event type (`:1` = press), not
199
+ // an alternate key code. Semicolon introduces the alternate code.
200
+ alternate: separator === ';' && third !== undefined && third !== '' ? Number.parseInt(third, 10) : undefined,
201
+ })
202
+ return legacy ?? whole
203
+ })
169
204
  }
170
205
 
171
206
  /** Editor actions Ink cannot distinguish reliably when terminal bytes batch. */