dsh-code 1.0.3 → 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/src/approval.ts CHANGED
@@ -96,6 +96,13 @@ export function mountApprovalAnswerer(
96
96
 
97
97
  let resolved = false
98
98
  let settle!: (outcome: ApprovalOutcome) => void
99
+ // Established BEFORE the abort listener mounts: a synchronous throw
100
+ // between the listener registration and a later construction site would
101
+ // otherwise leave a subsequent abort invoking an unassigned settle from
102
+ // inside the AbortSignal listener (an uncaughtException).
103
+ const settled = new Promise<ApprovalOutcome>((resolve) => {
104
+ settle = resolve
105
+ })
99
106
  const signal = request.signal
100
107
  const onAbort = (): void => withdraw()
101
108
  // Detach on every settle so an answered ask never retains a listener on
@@ -136,9 +143,7 @@ export function mountApprovalAnswerer(
136
143
  queue.push(slot)
137
144
  publish()
138
145
 
139
- return new Promise<ApprovalOutcome>((resolve) => {
140
- settle = resolve
141
- }).then((outcome) => {
146
+ return settled.then((outcome) => {
142
147
  if (outcome !== 'cancelled') {
143
148
  removeSlot(slot)
144
149
  publish()
@@ -196,8 +196,7 @@ export function ProviderAuthorizationPanel(props: ProviderAuthorizationPanelProp
196
196
  if (input !== '' && !key.ctrl && !key.meta) setDraft(current => current + input)
197
197
  })
198
198
 
199
- if (viewport.maxHeight === 0) return createElement(Box, { display: 'none' })
200
- if (viewport.compact) {
199
+ if (viewport.maxHeight === 0 || viewport.compact) {
201
200
  return createElement(Text, { wrap: 'truncate-end' }, truncateColumns('provider login · esc cancel', viewport.contentColumns))
202
201
  }
203
202
 
@@ -271,8 +270,7 @@ export function ProviderAuthorizationLogoutPanel({ row, confirm, done, back }: {
271
270
  setError(reason instanceof Error ? reason.message : String(reason))
272
271
  })
273
272
  })
274
- if (viewport.maxHeight === 0) return createElement(Box, { display: 'none' })
275
- if (viewport.compact) return createElement(Text, { wrap: 'truncate-end' }, truncateColumns('y logout · n/esc back', viewport.contentColumns))
273
+ if (viewport.maxHeight === 0 || viewport.compact) return createElement(Text, { wrap: 'truncate-end' }, truncateColumns('y logout · n/esc back', viewport.contentColumns))
276
274
  return createElement(
277
275
  Box,
278
276
  { flexDirection: 'column', width: viewport.outerColumns, paddingX: 1, borderStyle: 'round', borderColor: inkColor(getPalette().warn) },
package/src/commands.ts CHANGED
@@ -38,16 +38,22 @@ export function watchCommands(ctx: Context): CommandsView {
38
38
  let agent: Agent | undefined
39
39
  let descriptors: readonly CommandDescriptor[] = []
40
40
  let error: string | undefined
41
+ // The agent whose scoped view the current descriptors were read for: a
42
+ // failure before this agent ever loaded clears the list instead of keeping
43
+ // another session's commands completable here.
44
+ let loadedFor: Agent | undefined
41
45
  const listeners = new Set<() => void>()
42
46
  const refresh = (): void => {
43
47
  if (commands === undefined || agent === undefined) return
44
48
  try {
45
49
  descriptors = commands.list(agent)
50
+ loadedFor = agent
46
51
  error = undefined
47
52
  } catch (cause: unknown) {
48
- // Keep the last good catalog, but change its identity so subscribers
49
- // can render the recoverable failure in /help.
50
- descriptors = [...descriptors]
53
+ // Keep the last good catalog for the SAME agent, but change its identity
54
+ // so subscribers can render the recoverable failure in /help; an agent
55
+ // that never loaded starts from empty.
56
+ descriptors = loadedFor === agent ? [...descriptors] : []
51
57
  error = cause instanceof Error ? cause.message : String(cause)
52
58
  }
53
59
  for (const listener of listeners) listener()
@@ -83,3 +89,21 @@ export function watchCommands(ctx: Context): CommandsView {
83
89
  export function isSlashLine(line: string): boolean {
84
90
  return /^\/[a-z][a-z0-9_-]*(?=$|[\t ])/u.test(line)
85
91
  }
92
+
93
+ /**
94
+ * The submission payload for one composer line. Trim is a blank check, not a
95
+ * rewrite: an ordinary prompt keeps its exact leading indentation, inner
96
+ * layout, and trailing spaces (pasted code must reach the model verbatim).
97
+ * Only trailing line terminators are stripped — a draft's final newline is a
98
+ * paste/Enter artifact (an open bracketed paste turns Enter into an inserted
99
+ * newline), never deliberate content. A syntactic slash line still normalizes
100
+ * fully so command routing stays stable (completion inserts a trailing space
101
+ * after `/name`).
102
+ * @param line - the complete draft text.
103
+ * @returns the text to submit verbatim.
104
+ */
105
+ export function submissionPayload(line: string): string {
106
+ const withoutTrailingNewlines = line.replace(/[\r\n]+$/u, '')
107
+ const trimmed = withoutTrailingNewlines.trim()
108
+ return isSlashLine(trimmed) ? trimmed : withoutTrailingNewlines
109
+ }
package/src/index.ts CHANGED
@@ -33,11 +33,12 @@ import type {} from '@deepseek-ai/cordis-plugin-loader'
33
33
  import type {} from '@deepseek-ai/dsh-cmdline'
34
34
  import { App, type NoticeTone } from './app.ts'
35
35
  import { mountApprovalAnswerer, type ApprovalStore } from './approval.ts'
36
- import { isSlashLine, watchCommands, type CommandsView } from './commands.ts'
36
+ import { isSlashLine, submissionPayload, watchCommands, type CommandsView } from './commands.ts'
37
37
  import { internals, type TuiMount } from './internals.ts'
38
38
  import { syncModelCapabilities } from './model-capabilities.ts'
39
39
  import { buildModelSelection, applyModelSelectionToConfig, loadModelDirectory, modelSelectionLabel, resolveEffectiveSelection, type ModelRow } from './models.ts'
40
40
  import {
41
+ discoverProviderModels,
41
42
  loadProviderSettings,
42
43
  removeProviderSettings,
43
44
  saveProviderCredential,
@@ -243,6 +244,48 @@ export async function runQuitSequence(
243
244
  return started
244
245
  }
245
246
 
247
+ /** One composer submission waiting behind the startup delivery. */
248
+ export interface QueuedSubmission {
249
+ readonly text: string
250
+ readonly mode: 'followup' | 'steer'
251
+ readonly images: readonly ImageBlock[]
252
+ }
253
+
254
+ /**
255
+ * Order-preserving gate for composer input while the startup prompt/images
256
+ * are still preparing. Anything submitted before the startup delivery settles
257
+ * queues and flushes afterwards in submit order, so the initial request can
258
+ * never be overtaken by typing that raced a slow image preparation. The flush
259
+ * also runs when the startup delivery fails: user input is never stranded.
260
+ */
261
+ export class StartupInputGate {
262
+ private readonly queued: QueuedSubmission[] = []
263
+ private pending = false
264
+ constructor(private readonly deliver: (submission: QueuedSubmission) => void) {}
265
+
266
+ /** Submit one line: delivered now while idle, queued behind the startup delivery otherwise. */
267
+ submit(submission: QueuedSubmission): void {
268
+ if (this.pending) this.queued.push(submission)
269
+ else this.deliver(submission)
270
+ }
271
+
272
+ /**
273
+ * Run the startup delivery — the callback receives the direct-delivery sink
274
+ * for the startup prompt itself — then flush everything that queued behind
275
+ * it, in order, even when the callback rejects.
276
+ */
277
+ async run(startup: (deliver: (submission: QueuedSubmission) => void) => Promise<void>): Promise<void> {
278
+ this.pending = true
279
+ try {
280
+ await startup(submission => this.deliver(submission))
281
+ } finally {
282
+ this.pending = false
283
+ const queued = this.queued.splice(0)
284
+ for (const submission of queued) this.deliver(submission)
285
+ }
286
+ }
287
+ }
288
+
246
289
  /**
247
290
  * Resolve the invocation's target session against the persisted headers.
248
291
  * @param startup - the parsed startup flags.
@@ -505,7 +548,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
505
548
  const commands: CommandsView = watchCommands(ctx)
506
549
  if (agent !== undefined) commands.setAgent(agent)
507
550
 
508
- const skills: SkillsView = watchSkills(ctx)
551
+ const skills: SkillsView = watchSkills(ctx, cwd)
509
552
  if (agent !== undefined) skills.setAgent(agent)
510
553
 
511
554
  // Approval answerer: renders the ask as a y/n bar; only this TUI's agent is
@@ -530,9 +573,17 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
530
573
  const subject = payload.agent
531
574
  const header = subject.session.header
532
575
  if (header.parentSession === undefined && header.origin !== 'subagent') return next()
576
+ // Only the ACTIVE session's explicit pick may steer a subagent request.
577
+ // During a switch window the old agent can still be mid-flight; routing
578
+ // it by the NEW session's pick sent one of its requests to the wrong
579
+ // model. A subject outside the active tree falls back to its own request
580
+ // header (plus any explicit /subagent override, which is user intent).
581
+ const activeAgent = active
582
+ const belongsToActive = activeAgent !== undefined
583
+ && (header.parentSession ?? subject.session.id) === activeAgent.session.id
533
584
  const picked = subagentOverride
534
585
  ?? resolveEffectiveSelection(
535
- active?.selection.picked ?? pendingSelection,
586
+ belongsToActive && activeAgent !== undefined ? (activeAgent.selection.picked ?? pendingSelection) : undefined,
536
587
  subject.session.requestHeader()?.config,
537
588
  currentDefaults(),
538
589
  )
@@ -695,12 +746,18 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
695
746
  off()
696
747
  for (const dispose of offCapabilitySync) dispose()
697
748
  if (capabilitySyncTimer !== undefined) clearTimeout(capabilitySyncTimer)
698
- mountRef.current?.unmount()
699
749
  const currentSession = session
700
750
  const currentActive = active
701
751
  const report = (name: string, error: unknown): void => {
702
752
  internals.stderr.write(`dsh: quit ${name} failed: ${error instanceof Error ? error.message : String(error)}\n`)
703
753
  }
754
+ // A throwing unmount must not strand the terminal (stdin tap alive,
755
+ // keyboard protocol stacks unpopped) or skip the exit sequence below.
756
+ try {
757
+ mountRef.current?.unmount()
758
+ } catch (error: unknown) {
759
+ report('unmount', error)
760
+ }
704
761
  // One ordered cleanup: settle the visible session (if any — a bare launch
705
762
  // that never composed one resolves immediately), then wait for the final
706
763
  // in-flight composition (its work swallows errors and the quitting guard
@@ -770,6 +827,9 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
770
827
  })
771
828
  }
772
829
 
830
+ /** Delivery serialization state: the chain's epoch pins it to one session. */
831
+ let deliveryChain: { epoch: number; tail: Promise<void> } = { epoch: 0, tail: Promise.resolve() }
832
+
773
833
  /** Deliver one trimmed line to the live session, expanding mentions first. */
774
834
  const deliverLine = (line: string, mode: 'followup' | 'steer', images: readonly ImageBlock[] = []): void => {
775
835
  const currentAgent = agent!
@@ -788,6 +848,15 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
788
848
  bridge.notify(`invalid session reference: ${error instanceof Error ? error.message : String(error)}`, 'error')
789
849
  return
790
850
  }
851
+ // Ordered delivery: the inbox order IS the user's message order. A line
852
+ // with session mentions prepares asynchronously, and a later plain line
853
+ // used to deliver synchronously past it. Every line now waits for the
854
+ // previous line of the same session; an epoch change (switch/quit)
855
+ // abandons the chain instead of gating the next session on the old one.
856
+ if (deliveryChain.epoch !== epoch) deliveryChain = { epoch, tail: Promise.resolve() }
857
+ const enqueueDelivery = (run: () => void): void => {
858
+ deliveryChain.tail = deliveryChain.tail.then(run)
859
+ }
791
860
  const atEpoch = epoch
792
861
  const deliver = (readable: string, context?: UserMessage): void => {
793
862
  // A switch/quit landed while the snapshot was being prepared: never
@@ -818,19 +887,19 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
818
887
  }
819
888
  }
820
889
  if (parsed.references.length === 0) {
821
- deliver(parsed.text)
890
+ enqueueDelivery(() => deliver(parsed.text))
822
891
  return
823
892
  }
824
893
  const controller = new AbortController()
825
894
  pendingControllers.add(controller)
826
- void currentMentions.prepare(parsed, controller.signal).then((prepared) => {
895
+ enqueueDelivery(() => currentMentions.prepare(parsed, controller.signal).then((prepared) => {
827
896
  pendingControllers.delete(controller)
828
897
  deliver(prepared.text, prepared.additionalContext)
829
898
  }, (error: unknown) => {
830
899
  pendingControllers.delete(controller)
831
900
  if (controller.signal.aborted || epoch !== atEpoch) return
832
901
  bridge.notify(`session reference failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
833
- })
902
+ }))
834
903
  }
835
904
 
836
905
  // Deferred first-session creation for a bare launch: the session is composed
@@ -868,22 +937,45 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
868
937
  void next.handle.dispose().catch(() => {})
869
938
  return
870
939
  }
871
- active = next
872
- agent = next.agent
873
- session = next.session
874
- store = next.store
875
- mentions = next.mentions
876
- subagents.reset()
877
- pendingMode = undefined
878
- pendingPermission = undefined
879
- commands.setAgent(agent)
880
- skills.setAgent(agent)
881
- // The App mounts with a placeholder key until the first input; the
882
- // key-change remount below must start from a clean screen or the ghost
883
- // static header stays visible above the new one (same source-backed
884
- // clear the session-switch path performs).
885
- process.stdout.write('\x1b[r\x1b[0m\x1b[H\x1b[2J\x1b[3J\x1b[H')
886
- renderCurrent()
940
+ const previous = { active, agent, session, store, mentions }
941
+ try {
942
+ active = next
943
+ agent = next.agent
944
+ session = next.session
945
+ store = next.store
946
+ mentions = next.mentions
947
+ subagents.reset()
948
+ pendingMode = undefined
949
+ pendingPermission = undefined
950
+ commands.setAgent(agent)
951
+ skills.setAgent(agent)
952
+ // The App mounts with a placeholder key until the first input; the
953
+ // key-change remount below must start from a clean screen or the ghost
954
+ // static header stays visible above the new one (same source-backed
955
+ // clear the session-switch path performs).
956
+ process.stdout.write('\x1b[r\x1b[0m\x1b[H\x1b[2J\x1b[3J\x1b[H')
957
+ renderCurrent()
958
+ } catch (error: unknown) {
959
+ // The session composed but the screen handoff threw (stdout EPIPE,
960
+ // a render-time failure). Roll the published state back exactly
961
+ // like the switch path does — otherwise the runner reports "session
962
+ // creation failed" while the new session is actually live, clears
963
+ // the queued inputs, and every later line lands in the ghost. The
964
+ // queued inputs are KEPT for the next attempt.
965
+ active = previous.active
966
+ agent = previous.agent
967
+ session = previous.session
968
+ store = previous.store === undefined ? createTranscriptStore() : previous.store
969
+ mentions = previous.mentions === undefined ? createMentions(ctx, undefined, cwd) : previous.mentions
970
+ if (agent !== undefined) {
971
+ commands.setAgent(agent)
972
+ skills.setAgent(agent)
973
+ }
974
+ await next.handle.dispose().catch(() => {})
975
+ if (!quitting) renderCurrent()
976
+ bridge.notify(`session activation failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
977
+ return
978
+ }
887
979
  abortPendingControllers()
888
980
  epoch += 1
889
981
  const queued = pendingInputs.splice(0)
@@ -898,9 +990,11 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
898
990
  }
899
991
 
900
992
  /** Deliver one readable line to the agent, expanding session mentions first. */
901
- const send = (text: string, mode: 'followup' | 'steer', images: readonly ImageBlock[] = []): void => {
902
- const line = text.trim()
903
- if (line === '' && images.length === 0) return
993
+ const sendNow = (text: string, mode: 'followup' | 'steer', images: readonly ImageBlock[] = []): void => {
994
+ // Blank check on the trimmed form; the payload itself keeps the draft's
995
+ // exact whitespace unless the line is a syntactic slash command.
996
+ const line = submissionPayload(text)
997
+ if (line.trim() === '' && images.length === 0) return
904
998
  if (images.length === 0 && line.startsWith('/mode ')) {
905
999
  void switchModeAction(line.slice(6).trim()).then(
906
1000
  selected => bridge.notify(`mode → ${selected}`),
@@ -925,6 +1019,13 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
925
1019
  deliverLine(line, mode, images)
926
1020
  }
927
1021
 
1022
+ // Startup serialization: input submitted while the startup prompt/images
1023
+ // are still preparing queues behind the initial request.
1024
+ const inputGate = new StartupInputGate(({ text, mode, images }) => sendNow(text, mode, images))
1025
+ const send = (text: string, mode: 'followup' | 'steer', images: readonly ImageBlock[] = []): void => {
1026
+ inputGate.submit({ text, mode, images })
1027
+ }
1028
+
928
1029
  /** Dispatch one submitted line: slash commands to the registry, other text to the agent. */
929
1030
  const dispatch = (text: string, images: readonly ImageBlock[] = []): void => {
930
1031
  send(text, 'followup', images)
@@ -1261,14 +1362,19 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
1261
1362
  session = next.session
1262
1363
  store = next.store
1263
1364
  mentions = next.mentions
1264
- subagents.reset()
1265
- pendingMode = undefined
1266
- pendingPermission = undefined
1267
1365
  commands.setAgent(agent)
1268
1366
  skills.setAgent(agent)
1269
1367
  try {
1270
1368
  process.stdout.write('\x1b[r\x1b[0m\x1b[H\x1b[2J\x1b[3J\x1b[H')
1271
1369
  renderCurrent()
1370
+ // Only a successful handoff may clear the transient per-session
1371
+ // surfaces: a rolled-back switch keeps the previous session's
1372
+ // subagent feed plus the user's pre-session /mode and permission
1373
+ // picks (the bare-launch promise: explicit choices survive until
1374
+ // composition takes them).
1375
+ subagents.reset()
1376
+ pendingMode = undefined
1377
+ pendingPermission = undefined
1272
1378
  } catch (error: unknown) {
1273
1379
  active = previous
1274
1380
  agent = previous?.agent
@@ -1288,7 +1394,13 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
1288
1394
  // No previous session (a bare launch switched straight into a resume):
1289
1395
  // nothing to flush or dispose, so just confirm the activation.
1290
1396
  if (previous === undefined) {
1291
- bridge.notify(`${next.resumed ? 'resumed' : 'created'} ${next.session.id.slice(-12)} · mode ${next.mode}`)
1397
+ // The key-change remount above swaps the App in this same synchronous
1398
+ // continuation; the new App registers its bridge.notify in a passive
1399
+ // effect AFTER it, so an immediate notice reaches the UNMOUNTED
1400
+ // instance and React drops it silently. Defer past the commit.
1401
+ setTimeout(() => {
1402
+ bridge.notify(`${next.resumed ? 'resumed' : 'created'} ${next.session.id.slice(-12)} · mode ${next.mode}`)
1403
+ }, 0)
1292
1404
  return
1293
1405
  }
1294
1406
  let cleanupWarning: string | undefined
@@ -1494,6 +1606,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
1494
1606
  subscribeModelProviders: listener => subscribeProviderSettings(ctx, listener),
1495
1607
  saveModelProviderCredential: (target, key) => saveProviderCredential(ctx, target, key),
1496
1608
  saveModelProviderConfiguration: (target, configuration) => saveProviderConfiguration(ctx, target, configuration),
1609
+ discoverModelProvider: (target, request, signal) => discoverProviderModels(ctx, target, request, signal),
1497
1610
  unsetModelProviderCredential: target => unsetProviderCredential(ctx, target),
1498
1611
  removeModelProvider: target => removeProviderSettings(ctx, target),
1499
1612
  loadProviderAuthorizations: () => loadProviderAuthorizations(ctx),
@@ -1557,18 +1670,20 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
1557
1670
  mountRef.current = io.mount(appElement())
1558
1671
 
1559
1672
  // Startup prompt/images use the same durable delivery path as composer
1560
- // submissions. Image bytes are committed before the user/message event.
1673
+ // submissions. Image bytes are committed before the user/message event, and
1674
+ // input typed during that preparation queues behind the initial request so
1675
+ // the agent always receives the startup prompt first.
1561
1676
  if (startup.prompt !== undefined || (startup.images?.length ?? 0) > 0) {
1562
1677
  if ((startup.images?.length ?? 0) > 0) {
1563
1678
  bridge.notify(`processing ${startup.images!.length} startup image${startup.images!.length === 1 ? '' : 's'}…`)
1564
1679
  }
1565
- void saveImagePaths(startup.images ?? [], ctx.get('attachments')).then(
1566
- images => {
1567
- if (images.length > 0) bridge.notify(`${images.length} startup image${images.length === 1 ? '' : 's'} attached`)
1568
- send(startup.prompt ?? '', 'followup', images)
1569
- },
1570
- (error: unknown) => bridge.notify(`initial prompt failed: ${error instanceof Error ? error.message : String(error)}`, 'error'),
1571
- )
1680
+ void inputGate.run(async deliver => {
1681
+ const images = await saveImagePaths(startup.images ?? [], ctx.get('attachments'))
1682
+ if (images.length > 0) bridge.notify(`${images.length} startup image${images.length === 1 ? '' : 's'} attached`)
1683
+ deliver({ text: startup.prompt ?? '', mode: 'followup', images })
1684
+ }).catch((error: unknown) => {
1685
+ bridge.notify(`initial prompt failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
1686
+ })
1572
1687
  }
1573
1688
 
1574
1689
  async function copyLastResponse(): Promise<string> {
@@ -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
  },