dsh-code 1.0.3 → 1.0.5

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 (45) hide show
  1. package/README.md +293 -285
  2. package/bin/deepseek.mjs +245 -12
  3. package/cordis.patch.yml +12 -14
  4. package/lib/index.mjs +1939 -903
  5. package/lib/types/app.d.ts +11 -2
  6. package/lib/types/commands.d.ts +13 -0
  7. package/lib/types/git-workflow.d.ts +7 -2
  8. package/lib/types/history.d.ts +18 -11
  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/presets.d.ts +4 -1
  14. package/lib/types/provider-settings.d.ts +77 -0
  15. package/lib/types/questions.d.ts +16 -12
  16. package/lib/types/render/projection.d.ts +9 -2
  17. package/lib/types/render/status.d.ts +22 -15
  18. package/lib/types/settings-file.d.ts +8 -0
  19. package/lib/types/skills.d.ts +1 -1
  20. package/package.json +49 -46
  21. package/src/app.ts +5459 -4900
  22. package/src/approval.ts +8 -3
  23. package/src/authorization-panel.ts +2 -4
  24. package/src/commands.ts +27 -3
  25. package/src/git-workflow.ts +29 -10
  26. package/src/history.ts +22 -13
  27. package/src/index.ts +203 -61
  28. package/src/input-split.ts +191 -0
  29. package/src/internals.ts +26 -8
  30. package/src/kernel-panels.ts +26 -10
  31. package/src/keyboard.ts +123 -88
  32. package/src/mentions.ts +42 -9
  33. package/src/permissions.ts +1 -1
  34. package/src/presets.ts +19 -6
  35. package/src/provider-settings.ts +204 -0
  36. package/src/questions.ts +58 -55
  37. package/src/render/export.ts +7 -7
  38. package/src/render/lines.ts +24 -12
  39. package/src/render/markdown.ts +15 -13
  40. package/src/render/projection.ts +101 -13
  41. package/src/render/status.ts +76 -71
  42. package/src/render/text.ts +9 -3
  43. package/src/settings-file.ts +38 -6
  44. package/src/skills.ts +19 -6
  45. package/src/theme-panel.ts +79 -72
package/src/index.ts CHANGED
@@ -12,7 +12,7 @@
12
12
  import { randomUUID } from 'node:crypto'
13
13
  import { readFileSync } from 'node:fs'
14
14
  import { homedir } from 'node:os'
15
- import { mkdir, rm, stat, writeFile as writeFileAsync } from 'node:fs/promises'
15
+ import { appendFile as appendFileAsync, mkdir, rm, stat, writeFile as writeFileAsync } from 'node:fs/promises'
16
16
  import { basename, dirname, join } from 'node:path'
17
17
  import { createElement } from 'react'
18
18
  import type { Context } from '@deepseek-ai/cordis'
@@ -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,
@@ -47,10 +48,13 @@ import {
47
48
  } from './provider-settings.ts'
48
49
  import { createMentions, type MentionsApi } from './mentions.ts'
49
50
  import { mountQuestionProvider, type QuestionStore } from './questions.ts'
51
+ // Type-only import merges the settings Events declarations ('settings/updated',
52
+ // 'settings/document-updated') into this program's Cordis bus typing.
53
+ import type {} from '@deepseek-ai/dsh-settings'
50
54
  import { createTranscriptStore, type TranscriptStore } from './store.ts'
51
55
  import { createSubagentFeed, type SubagentFeedView } from './subagents.ts'
52
56
  import { parseStatuslineItems } from './render/status.ts'
53
- import { HISTORY_MAX_ENTRIES, parseHistoryFile, serializeHistoryList } from './history.ts'
57
+ import { historyLine, HISTORY_MAX_ENTRIES, needsCompaction, parseHistoryFile, serializeHistoryList } from './history.ts'
54
58
  import { watchSkills, type SkillsView } from './skills.ts'
55
59
  import { toolArgumentsPreview } from './render/tool-preview.ts'
56
60
  import { buildExportMarkdown } from './render/export.ts'
@@ -69,7 +73,7 @@ import { selectForkSeed } from './fork.ts'
69
73
  import { buildReviewPrompt, loadGitDiff } from './git-workflow.ts'
70
74
  import type { TuiStartup } from './startup.ts'
71
75
  import { SessionSwitchQueue } from './session-switch.ts'
72
- import { agentPresetsFrom, resolvePreset, selectPreset } from './presets.ts'
76
+ import { agentPresetsFrom, normalizePresetId, resolvePreset, selectPreset } from './presets.ts'
73
77
  import {
74
78
  applyPendingPermission,
75
79
  cyclePermission as cyclePermissionPreset,
@@ -93,7 +97,7 @@ import {
93
97
  type SessionQueryService,
94
98
  type SessionRow,
95
99
  } from './session-directory.ts'
96
- import { createUserSettingsPersistence } from './settings-file.ts'
100
+ import { createUserSettingsPersistence, writeFileAtomically } from './settings-file.ts'
97
101
 
98
102
  /** Stable Cordis plugin name. */
99
103
  export const name = 'tui-runner'
@@ -243,6 +247,48 @@ export async function runQuitSequence(
243
247
  return started
244
248
  }
245
249
 
250
+ /** One composer submission waiting behind the startup delivery. */
251
+ export interface QueuedSubmission {
252
+ readonly text: string
253
+ readonly mode: 'followup' | 'steer'
254
+ readonly images: readonly ImageBlock[]
255
+ }
256
+
257
+ /**
258
+ * Order-preserving gate for composer input while the startup prompt/images
259
+ * are still preparing. Anything submitted before the startup delivery settles
260
+ * queues and flushes afterwards in submit order, so the initial request can
261
+ * never be overtaken by typing that raced a slow image preparation. The flush
262
+ * also runs when the startup delivery fails: user input is never stranded.
263
+ */
264
+ export class StartupInputGate {
265
+ private readonly queued: QueuedSubmission[] = []
266
+ private pending = false
267
+ constructor(private readonly deliver: (submission: QueuedSubmission) => void) {}
268
+
269
+ /** Submit one line: delivered now while idle, queued behind the startup delivery otherwise. */
270
+ submit(submission: QueuedSubmission): void {
271
+ if (this.pending) this.queued.push(submission)
272
+ else this.deliver(submission)
273
+ }
274
+
275
+ /**
276
+ * Run the startup delivery — the callback receives the direct-delivery sink
277
+ * for the startup prompt itself — then flush everything that queued behind
278
+ * it, in order, even when the callback rejects.
279
+ */
280
+ async run(startup: (deliver: (submission: QueuedSubmission) => void) => Promise<void>): Promise<void> {
281
+ this.pending = true
282
+ try {
283
+ await startup(submission => this.deliver(submission))
284
+ } finally {
285
+ this.pending = false
286
+ const queued = this.queued.splice(0)
287
+ for (const submission of queued) this.deliver(submission)
288
+ }
289
+ }
290
+ }
291
+
246
292
  /**
247
293
  * Resolve the invocation's target session against the persisted headers.
248
294
  * @param startup - the parsed startup flags.
@@ -363,7 +409,9 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
363
409
  ? {}
364
410
  : { picked: pendingSelection }
365
411
  let mode = next.resume ? next.mode : next.mode ?? pendingMode
366
- if (!next.resume) mode = (await presets.resolve(mode)).id
412
+ // An explicit `--mode` or the settings-layer service default may still name
413
+ // an id an upstream rename retired (code → ptc); normalize both.
414
+ if (!next.resume) mode = (await presets.resolve(normalizePresetId(mode ?? presets.defaultId))).id
367
415
  const setup = async (agentCtx: Context): Promise<void> => {
368
416
  const sessionPreset = next.resume
369
417
  ? resolvePreset(agentCtx.agent!.session)
@@ -414,7 +462,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
414
462
  handle,
415
463
  agent: handle.agent,
416
464
  session,
417
- store: createTranscriptStore(session.events),
465
+ store: createTranscriptStore(session.snapshotEvents()),
418
466
  mentions: createMentions(ctx, handle.agent, session.header.cwd ?? nextCwd),
419
467
  mode: mode ?? 'standard',
420
468
  selection: selectionState,
@@ -505,7 +553,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
505
553
  const commands: CommandsView = watchCommands(ctx)
506
554
  if (agent !== undefined) commands.setAgent(agent)
507
555
 
508
- const skills: SkillsView = watchSkills(ctx)
556
+ const skills: SkillsView = watchSkills(ctx, cwd)
509
557
  if (agent !== undefined) skills.setAgent(agent)
510
558
 
511
559
  // Approval answerer: renders the ask as a y/n bar; only this TUI's agent is
@@ -530,19 +578,30 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
530
578
  const subject = payload.agent
531
579
  const header = subject.session.header
532
580
  if (header.parentSession === undefined && header.origin !== 'subagent') return next()
581
+ // Only the ACTIVE session's explicit pick may steer a subagent request.
582
+ // During a switch window the old agent can still be mid-flight; routing
583
+ // it by the NEW session's pick sent one of its requests to the wrong
584
+ // model. A subject outside the active tree falls back to its own request
585
+ // header (plus any explicit /subagent override, which is user intent).
586
+ const activeAgent = active
587
+ const belongsToActive = activeAgent !== undefined
588
+ && (header.parentSession ?? subject.session.id) === activeAgent.session.id
533
589
  const picked = subagentOverride
534
590
  ?? resolveEffectiveSelection(
535
- active?.selection.picked ?? pendingSelection,
591
+ belongsToActive && activeAgent !== undefined ? (activeAgent.selection.picked ?? pendingSelection) : undefined,
536
592
  subject.session.requestHeader()?.config,
537
593
  currentDefaults(),
538
594
  )
539
595
  return next().then(resolved => applyModelSelectionToConfig(resolved, picked))
540
596
  })
541
597
 
542
- // ask_user_question provider: the single UI provider on the shared service,
543
- // one request on screen at a time. Plan reviews (exit_plan_mode) arrive
544
- // through this same pipe.
545
- const questions: QuestionStore = mountQuestionProvider(ctx)
598
+ // ask_user_question answerer: one waterfall listener, one request on
599
+ // screen at a time. Plan reviews (exit_plan_mode) arrive through this same
600
+ // pipe; sibling answerers stay usable through the claim/defer split.
601
+ const questions: QuestionStore = mountQuestionProvider(
602
+ ctx,
603
+ candidate => agent !== undefined && candidate.id === agent.id,
604
+ )
546
605
 
547
606
  // The bridge the React app registers on mount: local notices from the
548
607
  // process side (unknown commands, switch confirmations, cancels).
@@ -645,22 +704,39 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
645
704
  // recall is a convenience surface, never a gate.
646
705
  const historyPath = join(homedir(), '.dsh', 'dsh-code', 'history.jsonl')
647
706
  let inputHistory: readonly string[] = []
707
+ let historyWriteChain: Promise<void> = Promise.resolve()
648
708
  try {
649
- inputHistory = parseHistoryFile(readFileSync(historyPath, 'utf8'))
709
+ const rawHistory = readFileSync(historyPath, 'utf8')
710
+ inputHistory = parseHistoryFile(rawHistory)
711
+ // Stale lines (adjacent duplicates, dropped garbage, an over-cap tail)
712
+ // accumulate in an append-only file; rewrite the canonical form once
713
+ // per boot. The rewrite rides the same chain, so it lands before any
714
+ // submission the user types next. An entry another terminal appends
715
+ // inside the read-to-rename window is dropped — a millisecond-scale
716
+ // gap at boot that recall tolerates by design.
717
+ if (needsCompaction(rawHistory)) {
718
+ historyWriteChain = historyWriteChain
719
+ .then(() => writeFileAtomically(historyPath, serializeHistoryList(inputHistory)))
720
+ .catch(() => {})
721
+ }
650
722
  } catch {
651
723
  inputHistory = []
652
724
  }
653
- /** Serialized history writes: each submission rewrites the latest in-memory snapshot. */
654
- let historyWriteChain: Promise<void> = Promise.resolve()
725
+ /**
726
+ * Serialized history writes: each submission appends one JSON line at the
727
+ * end of the file, so concurrent terminals add entries after each other
728
+ * instead of overwriting snapshots they read at their own boot. A
729
+ * multi-line draft still occupies one physical line (JSON escapes the
730
+ * newline), and a regular-length line reaches the disk as one positioned
731
+ * write; an oversized paste may interleave mid-line, which the next
732
+ * parse simply drops.
733
+ */
655
734
  const recordHistory = (text: string): void => {
656
735
  if (text === '') return
657
736
  inputHistory = [...inputHistory, text].slice(-HISTORY_MAX_ENTRIES)
658
- // Write the whole current list, serialized per submission: the file is
659
- // never read back on the submit path, so rapid same-process submissions
660
- // cannot lose entries to a read-modify-write race.
661
737
  historyWriteChain = historyWriteChain
662
738
  .then(() => mkdir(dirname(historyPath), { recursive: true }))
663
- .then(() => writeFileAsync(historyPath, serializeHistoryList(inputHistory), 'utf8'))
739
+ .then(() => appendFileAsync(historyPath, historyLine(text), 'utf8'))
664
740
  .catch((writeError: unknown) => {
665
741
  bridge.notify('history save failed: ' + (writeError instanceof Error ? writeError.message : String(writeError)), 'error')
666
742
  })
@@ -695,12 +771,18 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
695
771
  off()
696
772
  for (const dispose of offCapabilitySync) dispose()
697
773
  if (capabilitySyncTimer !== undefined) clearTimeout(capabilitySyncTimer)
698
- mountRef.current?.unmount()
699
774
  const currentSession = session
700
775
  const currentActive = active
701
776
  const report = (name: string, error: unknown): void => {
702
777
  internals.stderr.write(`dsh: quit ${name} failed: ${error instanceof Error ? error.message : String(error)}\n`)
703
778
  }
779
+ // A throwing unmount must not strand the terminal (stdin tap alive,
780
+ // keyboard protocol stacks unpopped) or skip the exit sequence below.
781
+ try {
782
+ mountRef.current?.unmount()
783
+ } catch (error: unknown) {
784
+ report('unmount', error)
785
+ }
704
786
  // One ordered cleanup: settle the visible session (if any — a bare launch
705
787
  // that never composed one resolves immediately), then wait for the final
706
788
  // in-flight composition (its work swallows errors and the quitting guard
@@ -770,6 +852,9 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
770
852
  })
771
853
  }
772
854
 
855
+ /** Delivery serialization state: the chain's epoch pins it to one session. */
856
+ let deliveryChain: { epoch: number; tail: Promise<void> } = { epoch: 0, tail: Promise.resolve() }
857
+
773
858
  /** Deliver one trimmed line to the live session, expanding mentions first. */
774
859
  const deliverLine = (line: string, mode: 'followup' | 'steer', images: readonly ImageBlock[] = []): void => {
775
860
  const currentAgent = agent!
@@ -788,6 +873,15 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
788
873
  bridge.notify(`invalid session reference: ${error instanceof Error ? error.message : String(error)}`, 'error')
789
874
  return
790
875
  }
876
+ // Ordered delivery: the inbox order IS the user's message order. A line
877
+ // with session mentions prepares asynchronously, and a later plain line
878
+ // used to deliver synchronously past it. Every line now waits for the
879
+ // previous line of the same session; an epoch change (switch/quit)
880
+ // abandons the chain instead of gating the next session on the old one.
881
+ if (deliveryChain.epoch !== epoch) deliveryChain = { epoch, tail: Promise.resolve() }
882
+ const enqueueDelivery = (run: () => void): void => {
883
+ deliveryChain.tail = deliveryChain.tail.then(run)
884
+ }
791
885
  const atEpoch = epoch
792
886
  const deliver = (readable: string, context?: UserMessage): void => {
793
887
  // A switch/quit landed while the snapshot was being prepared: never
@@ -818,19 +912,19 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
818
912
  }
819
913
  }
820
914
  if (parsed.references.length === 0) {
821
- deliver(parsed.text)
915
+ enqueueDelivery(() => deliver(parsed.text))
822
916
  return
823
917
  }
824
918
  const controller = new AbortController()
825
919
  pendingControllers.add(controller)
826
- void currentMentions.prepare(parsed, controller.signal).then((prepared) => {
920
+ enqueueDelivery(() => currentMentions.prepare(parsed, controller.signal).then((prepared) => {
827
921
  pendingControllers.delete(controller)
828
922
  deliver(prepared.text, prepared.additionalContext)
829
923
  }, (error: unknown) => {
830
924
  pendingControllers.delete(controller)
831
925
  if (controller.signal.aborted || epoch !== atEpoch) return
832
926
  bridge.notify(`session reference failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
833
- })
927
+ }))
834
928
  }
835
929
 
836
930
  // Deferred first-session creation for a bare launch: the session is composed
@@ -868,22 +962,45 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
868
962
  void next.handle.dispose().catch(() => {})
869
963
  return
870
964
  }
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()
965
+ const previous = { active, agent, session, store, mentions }
966
+ try {
967
+ active = next
968
+ agent = next.agent
969
+ session = next.session
970
+ store = next.store
971
+ mentions = next.mentions
972
+ subagents.reset()
973
+ pendingMode = undefined
974
+ pendingPermission = undefined
975
+ commands.setAgent(agent)
976
+ skills.setAgent(agent)
977
+ // The App mounts with a placeholder key until the first input; the
978
+ // key-change remount below must start from a clean screen or the ghost
979
+ // static header stays visible above the new one (same source-backed
980
+ // clear the session-switch path performs).
981
+ process.stdout.write('\x1b[r\x1b[0m\x1b[H\x1b[2J\x1b[3J\x1b[H')
982
+ renderCurrent()
983
+ } catch (error: unknown) {
984
+ // The session composed but the screen handoff threw (stdout EPIPE,
985
+ // a render-time failure). Roll the published state back exactly
986
+ // like the switch path does — otherwise the runner reports "session
987
+ // creation failed" while the new session is actually live, clears
988
+ // the queued inputs, and every later line lands in the ghost. The
989
+ // queued inputs are KEPT for the next attempt.
990
+ active = previous.active
991
+ agent = previous.agent
992
+ session = previous.session
993
+ store = previous.store === undefined ? createTranscriptStore() : previous.store
994
+ mentions = previous.mentions === undefined ? createMentions(ctx, undefined, cwd) : previous.mentions
995
+ if (agent !== undefined) {
996
+ commands.setAgent(agent)
997
+ skills.setAgent(agent)
998
+ }
999
+ await next.handle.dispose().catch(() => {})
1000
+ if (!quitting) renderCurrent()
1001
+ bridge.notify(`session activation failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
1002
+ return
1003
+ }
887
1004
  abortPendingControllers()
888
1005
  epoch += 1
889
1006
  const queued = pendingInputs.splice(0)
@@ -898,9 +1015,11 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
898
1015
  }
899
1016
 
900
1017
  /** 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
1018
+ const sendNow = (text: string, mode: 'followup' | 'steer', images: readonly ImageBlock[] = []): void => {
1019
+ // Blank check on the trimmed form; the payload itself keeps the draft's
1020
+ // exact whitespace unless the line is a syntactic slash command.
1021
+ const line = submissionPayload(text)
1022
+ if (line.trim() === '' && images.length === 0) return
904
1023
  if (images.length === 0 && line.startsWith('/mode ')) {
905
1024
  void switchModeAction(line.slice(6).trim()).then(
906
1025
  selected => bridge.notify(`mode → ${selected}`),
@@ -925,6 +1044,13 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
925
1044
  deliverLine(line, mode, images)
926
1045
  }
927
1046
 
1047
+ // Startup serialization: input submitted while the startup prompt/images
1048
+ // are still preparing queues behind the initial request.
1049
+ const inputGate = new StartupInputGate(({ text, mode, images }) => sendNow(text, mode, images))
1050
+ const send = (text: string, mode: 'followup' | 'steer', images: readonly ImageBlock[] = []): void => {
1051
+ inputGate.submit({ text, mode, images })
1052
+ }
1053
+
928
1054
  /** Dispatch one submitted line: slash commands to the registry, other text to the agent. */
929
1055
  const dispatch = (text: string, images: readonly ImageBlock[] = []): void => {
930
1056
  send(text, 'followup', images)
@@ -1261,14 +1387,19 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
1261
1387
  session = next.session
1262
1388
  store = next.store
1263
1389
  mentions = next.mentions
1264
- subagents.reset()
1265
- pendingMode = undefined
1266
- pendingPermission = undefined
1267
1390
  commands.setAgent(agent)
1268
1391
  skills.setAgent(agent)
1269
1392
  try {
1270
1393
  process.stdout.write('\x1b[r\x1b[0m\x1b[H\x1b[2J\x1b[3J\x1b[H')
1271
1394
  renderCurrent()
1395
+ // Only a successful handoff may clear the transient per-session
1396
+ // surfaces: a rolled-back switch keeps the previous session's
1397
+ // subagent feed plus the user's pre-session /mode and permission
1398
+ // picks (the bare-launch promise: explicit choices survive until
1399
+ // composition takes them).
1400
+ subagents.reset()
1401
+ pendingMode = undefined
1402
+ pendingPermission = undefined
1272
1403
  } catch (error: unknown) {
1273
1404
  active = previous
1274
1405
  agent = previous?.agent
@@ -1288,7 +1419,13 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
1288
1419
  // No previous session (a bare launch switched straight into a resume):
1289
1420
  // nothing to flush or dispose, so just confirm the activation.
1290
1421
  if (previous === undefined) {
1291
- bridge.notify(`${next.resumed ? 'resumed' : 'created'} ${next.session.id.slice(-12)} · mode ${next.mode}`)
1422
+ // The key-change remount above swaps the App in this same synchronous
1423
+ // continuation; the new App registers its bridge.notify in a passive
1424
+ // effect AFTER it, so an immediate notice reaches the UNMOUNTED
1425
+ // instance and React drops it silently. Defer past the commit.
1426
+ setTimeout(() => {
1427
+ bridge.notify(`${next.resumed ? 'resumed' : 'created'} ${next.session.id.slice(-12)} · mode ${next.mode}`)
1428
+ }, 0)
1292
1429
  return
1293
1430
  }
1294
1431
  let cleanupWarning: string | undefined
@@ -1371,11 +1508,13 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
1371
1508
  }
1372
1509
 
1373
1510
  const reviewChanges = (argument: string): void => {
1511
+ // Works from a bare launch too: with no session yet the read-only
1512
+ // choice goes to pendingPermission (materialized when the first
1513
+ // session composes) and the review prompt queues behind that
1514
+ // creation exactly like a typed first submission. The identity guard
1515
+ // below still aborts a load that outlives a mid-flight switch —
1516
+ // including one landing on an undefined agent.
1374
1517
  const currentAgent = agent
1375
- if (currentAgent === undefined) {
1376
- bridge.notify('no session yet - submit a message to start', 'warning')
1377
- return
1378
- }
1379
1518
  // The diff loads from the CALLING session's cwd; capture that
1380
1519
  // workspace and this turn's identity so a switch mid-load can neither
1381
1520
  // flip the new session read-only nor send the old workspace's review
@@ -1417,7 +1556,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
1417
1556
  if (text !== '' && (!Number.isSafeInteger(atSeq) || (atSeq ?? -1) < 0)) {
1418
1557
  throw new Error('usage: /fork [event-seq]')
1419
1558
  }
1420
- const seed = selectForkSeed(session.events, atSeq)
1559
+ const seed = selectForkSeed(session.snapshotEvents(), atSeq)
1421
1560
  const id = `session-${randomUUID()}`
1422
1561
  requestSwitch({
1423
1562
  target: {
@@ -1483,7 +1622,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
1483
1622
  branch: gitBranch(sessionCwd),
1484
1623
  sessionId: session === undefined ? '' : session.id.slice(-8),
1485
1624
  resumed: active?.resumed ?? false,
1486
- mode: active?.mode ?? pendingMode ?? presets.defaultId,
1625
+ mode: active?.mode ?? pendingMode ?? normalizePresetId(presets.defaultId),
1487
1626
  permission,
1488
1627
  dispatch,
1489
1628
  steer,
@@ -1494,6 +1633,7 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
1494
1633
  subscribeModelProviders: listener => subscribeProviderSettings(ctx, listener),
1495
1634
  saveModelProviderCredential: (target, key) => saveProviderCredential(ctx, target, key),
1496
1635
  saveModelProviderConfiguration: (target, configuration) => saveProviderConfiguration(ctx, target, configuration),
1636
+ discoverModelProvider: (target, request, signal) => discoverProviderModels(ctx, target, request, signal),
1497
1637
  unsetModelProviderCredential: target => unsetProviderCredential(ctx, target),
1498
1638
  removeModelProvider: target => removeProviderSettings(ctx, target),
1499
1639
  loadProviderAuthorizations: () => loadProviderAuthorizations(ctx),
@@ -1557,18 +1697,20 @@ async function run(ctx: Context, startup: TuiStartup, io: TuiIo): Promise<void>
1557
1697
  mountRef.current = io.mount(appElement())
1558
1698
 
1559
1699
  // Startup prompt/images use the same durable delivery path as composer
1560
- // submissions. Image bytes are committed before the user/message event.
1700
+ // submissions. Image bytes are committed before the user/message event, and
1701
+ // input typed during that preparation queues behind the initial request so
1702
+ // the agent always receives the startup prompt first.
1561
1703
  if (startup.prompt !== undefined || (startup.images?.length ?? 0) > 0) {
1562
1704
  if ((startup.images?.length ?? 0) > 0) {
1563
1705
  bridge.notify(`processing ${startup.images!.length} startup image${startup.images!.length === 1 ? '' : 's'}…`)
1564
1706
  }
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
- )
1707
+ void inputGate.run(async deliver => {
1708
+ const images = await saveImagePaths(startup.images ?? [], ctx.get('attachments'))
1709
+ if (images.length > 0) bridge.notify(`${images.length} startup image${images.length === 1 ? '' : 's'} attached`)
1710
+ deliver({ text: startup.prompt ?? '', mode: 'followup', images })
1711
+ }).catch((error: unknown) => {
1712
+ bridge.notify(`initial prompt failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
1713
+ })
1572
1714
  }
1573
1715
 
1574
1716
  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
+ }