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.
- package/README.md +293 -285
- package/bin/deepseek.mjs +245 -12
- package/cordis.patch.yml +12 -14
- package/lib/index.mjs +1939 -903
- package/lib/types/app.d.ts +11 -2
- package/lib/types/commands.d.ts +13 -0
- package/lib/types/git-workflow.d.ts +7 -2
- package/lib/types/history.d.ts +18 -11
- 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/presets.d.ts +4 -1
- package/lib/types/provider-settings.d.ts +77 -0
- package/lib/types/questions.d.ts +16 -12
- package/lib/types/render/projection.d.ts +9 -2
- package/lib/types/render/status.d.ts +22 -15
- package/lib/types/settings-file.d.ts +8 -0
- package/lib/types/skills.d.ts +1 -1
- package/package.json +49 -46
- package/src/app.ts +5459 -4900
- package/src/approval.ts +8 -3
- package/src/authorization-panel.ts +2 -4
- package/src/commands.ts +27 -3
- package/src/git-workflow.ts +29 -10
- package/src/history.ts +22 -13
- package/src/index.ts +203 -61
- 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/permissions.ts +1 -1
- package/src/presets.ts +19 -6
- package/src/provider-settings.ts +204 -0
- package/src/questions.ts +58 -55
- package/src/render/export.ts +7 -7
- package/src/render/lines.ts +24 -12
- package/src/render/markdown.ts +15 -13
- package/src/render/projection.ts +101 -13
- package/src/render/status.ts +76 -71
- package/src/render/text.ts +9 -3
- package/src/settings-file.ts +38 -6
- package/src/skills.ts +19 -6
- 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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
543
|
-
//
|
|
544
|
-
// through
|
|
545
|
-
const questions: QuestionStore = mountQuestionProvider(
|
|
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
|
-
|
|
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
|
-
/**
|
|
654
|
-
|
|
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(() =>
|
|
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
|
-
|
|
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
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
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
|
|
902
|
-
|
|
903
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
1566
|
-
images
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
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
|
+
}
|