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.
- package/README.en.md +21 -13
- package/README.md +285 -271
- package/bin/deepseek.mjs +26 -3
- package/lib/index.mjs +2962 -1560
- package/lib/types/app.d.ts +13 -2
- package/lib/types/commands.d.ts +13 -0
- package/lib/types/editor-keys.d.ts +105 -0
- package/lib/types/git-workflow.d.ts +6 -2
- package/lib/types/index.d.ts +28 -0
- package/lib/types/input-split.d.ts +54 -0
- package/lib/types/kernel-panels.d.ts +3 -1
- package/lib/types/keyboard.d.ts +8 -0
- package/lib/types/model-capabilities.d.ts +82 -0
- package/lib/types/provider-settings.d.ts +84 -0
- package/lib/types/render/lines.d.ts +25 -0
- package/lib/types/render/markdown.d.ts +1 -1
- package/lib/types/render/projection.d.ts +22 -2
- package/lib/types/render/status.d.ts +22 -15
- package/lib/types/render/text.d.ts +15 -9
- package/lib/types/render/width.d.ts +29 -0
- package/lib/types/session-directory.d.ts +27 -0
- package/lib/types/settings-file.d.ts +33 -0
- package/lib/types/skills.d.ts +1 -1
- package/lib/types/store.d.ts +10 -0
- package/lib/types/subagents.d.ts +13 -3
- package/package.json +159 -159
- package/src/app.ts +4514 -3892
- package/src/approval.ts +8 -3
- package/src/authorization-panel.ts +2 -4
- package/src/commands.ts +27 -3
- package/src/editor-keys.ts +371 -0
- package/src/git-workflow.ts +10 -6
- package/src/index.ts +1752 -1523
- package/src/input-split.ts +191 -0
- package/src/internals.ts +26 -8
- package/src/kernel-panels.ts +26 -10
- package/src/keyboard.ts +123 -88
- package/src/mentions.ts +42 -9
- package/src/model-capabilities.ts +318 -0
- package/src/provider-settings.ts +220 -0
- package/src/questions.ts +20 -0
- package/src/render/lines.ts +415 -356
- package/src/render/markdown.ts +18 -19
- package/src/render/projection.ts +162 -52
- package/src/render/status.ts +76 -71
- package/src/render/text.ts +158 -150
- package/src/render/width.ts +189 -0
- package/src/session-directory.ts +56 -0
- package/src/settings-file.ts +56 -0
- package/src/skills.ts +19 -6
- package/src/store.ts +26 -7
- package/src/subagents.ts +39 -6
- 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
|
-
|
|
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
|
-
|
|
62
|
-
//
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
},
|
package/src/kernel-panels.ts
CHANGED
|
@@ -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
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
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+))?(?:
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
if (
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
if (
|
|
140
|
-
if (
|
|
141
|
-
return
|
|
142
|
-
}
|
|
143
|
-
if (key.code >=
|
|
144
|
-
|
|
145
|
-
if (
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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. */
|