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/README.md +291 -285
- package/bin/deepseek.mjs +26 -3
- package/lib/index.mjs +2749 -1783
- package/lib/types/app.d.ts +11 -2
- package/lib/types/commands.d.ts +13 -0
- 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/provider-settings.d.ts +77 -0
- package/lib/types/render/projection.d.ts +7 -1
- package/lib/types/render/status.d.ts +22 -15
- package/lib/types/skills.d.ts +1 -1
- package/package.json +1 -1
- 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/index.ts +153 -38
- 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/provider-settings.ts +204 -0
- package/src/questions.ts +20 -0
- package/src/render/lines.ts +24 -12
- package/src/render/markdown.ts +15 -13
- package/src/render/projection.ts +99 -12
- package/src/render/status.ts +76 -71
- package/src/render/text.ts +9 -3
- package/src/skills.ts +19 -6
- package/src/theme-panel.ts +79 -72
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
|
|
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
|
|
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(
|
|
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
|
|
49
|
-
// can render the recoverable failure in /help
|
|
50
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
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
|
|
902
|
-
|
|
903
|
-
|
|
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
|
-
|
|
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
|
|
1566
|
-
images
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
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
|
-
|
|
56
|
+
// stdin travels through the keypress splitter: Ink parses one chunk as
|
|
57
|
+
// one keypress, so a coalesced space-then-enter would drop both keys.
|
|
58
|
+
const tuiStdin = createSplitStdin(process.stdin)
|
|
59
|
+
// Ink only touches isTTY/setRawMode/ref/read on stdin; the object-mode
|
|
60
|
+
// proxy satisfies that contract without the full ReadStream surface.
|
|
61
|
+
const instance = render(element, {
|
|
62
|
+
exitOnCtrlC: false,
|
|
63
|
+
stdin: tuiStdin.stdin as unknown as NodeJS.ReadStream,
|
|
64
|
+
stdout: process.stdout,
|
|
65
|
+
})
|
|
56
66
|
return {
|
|
57
67
|
rerender(element: ReactElement): void {
|
|
58
68
|
instance.rerender(element)
|
|
59
69
|
},
|
|
60
70
|
unmount(): void {
|
|
61
|
-
|
|
62
|
-
//
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
71
|
+
// The cleanup below must run even when Ink's unmount throws (a
|
|
72
|
+
// render-teardown failure): a stdin tap or pushed terminal-protocol
|
|
73
|
+
// stack outliving the app wedges the terminal for whatever runs
|
|
74
|
+
// next, and a stray exception here must not skip the exit sequence.
|
|
75
|
+
try {
|
|
76
|
+
instance.unmount()
|
|
77
|
+
} finally {
|
|
78
|
+
tuiStdin.dispose()
|
|
79
|
+
// Pop only a stack this mount pushed, then disable bracketed paste.
|
|
80
|
+
process.stdout.write(
|
|
81
|
+
(keyboardEnhanced ? KEYBOARD_ENHANCE_DISABLE : '')
|
|
82
|
+
+ BRACKETED_PASTE_DISABLE
|
|
83
|
+
+ (focusReporting ? TERMINAL_FOCUS_REPORT_DISABLE : ''),
|
|
84
|
+
)
|
|
85
|
+
}
|
|
68
86
|
},
|
|
69
87
|
}
|
|
70
88
|
},
|