@@ -15,10 +15,12 @@ import type {
1515 PluginCommandDef ,
1616 PromptPart ,
1717 Session ,
18+ SessionEnvironmentType ,
1819 SkillSummary ,
1920 TokenUsage ,
2021 TurnEndedEvent ,
2122 TurnStartedEvent ,
23+ WorkspaceEnvironmentDeclarationInfo ,
2224 WorkspaceTrustInfo ,
2325} from '@moonshot-ai/kimi-code-sdk' ;
2426import { isTelemetryDisabledByEnv } from '@moonshot-ai/kimi-telemetry' ;
@@ -313,6 +315,12 @@ interface SendMessageOptions {
313315 * it — the queue item owns the raw ids and re-leases at dequeue.
314316 */
315317 readonly lease ?: StagingLease ;
318+ /**
319+ * Set when the caller already appended the user transcript entry and ran
320+ * beginSessionRequest (the pre-session announce in sendNormalUserInput):
321+ * dispatch skips both and goes straight to the prompt.
322+ */
323+ readonly announced ?: boolean ;
316324}
317325
318326/** How long the one-shot "moved to background" footer hint stays visible. */
@@ -340,6 +348,8 @@ export class KimiTUI {
340348 state : TUIState ;
341349 /** In-flight lazy session creation (v2 engine), shared by concurrent first-use triggers. */
342350 private ensureSessionPromise : Promise < Session | undefined > | null = null ;
351+ /** Type of the validated `--environment` startup declaration, used for the synthetic connecting footer slot. */
352+ private startupEnvironmentType : SessionEnvironmentType | undefined ;
343353 private readonly cacheHint = new CacheHintController ( this ) ;
344354 /** Staged prompt media lifecycle (daemon uploads + cache copies) — see StagingLeaseTracker. */
345355 private readonly staging : StagingLeaseTracker ;
@@ -670,7 +680,20 @@ export class KimiTUI {
670680 }
671681
672682 startupTrace ( 'initMainTui:begin' ) ;
673- const shouldReplayHistory = await this . initMainTui ( ) ;
683+ let shouldReplayHistory : boolean ;
684+ try {
685+ shouldReplayHistory = await this . initMainTui ( ) ;
686+ } catch ( error ) {
687+ // The event loop runs here only when the trust prompt already started
688+ // it; a startup failure then (e.g. an unknown --environment id failing
689+ // fast) must not leak raw mode into the user's shell — mirror the
690+ // migration branch's cleanup.
691+ if ( trustPromptStartedLoop ) {
692+ this . disposeTerminalTracking ( ) ;
693+ this . state . ui . stop ( ) ;
694+ }
695+ throw error ;
696+ }
674697 startupTrace ( 'initMainTui:end' ) ;
675698 // Debug-only input→render latency overlay (KIMI_TUI_INPUT_LATENCY=1).
676699 if ( process . env [ 'KIMI_TUI_INPUT_LATENCY' ] ) installInputLatencyProbe ( this . state . ui ) ;
@@ -978,6 +1001,9 @@ export class KimiTUI {
9781001 // time (model, permission, plan mode, thinking effort, context cap).
9791002 await this . hydrateLazyConfigDefaults ( ) ;
9801003 this . appendStartupNotice ( SESSIONLESS_STARTUP_NOTICE ) ;
1004+ if ( startup . environment !== undefined && startup . environment !== 'local' ) {
1005+ await this . prepareStartupEnvironment ( startup . environment ) ;
1006+ }
9811007 }
9821008 if ( session !== undefined && shouldReplayHistory ) {
9831009 await this . applyStartupModesToResumedSession ( session ) ;
@@ -1448,9 +1474,55 @@ export class KimiTUI {
14481474 }
14491475 let session = this . session ;
14501476 if ( session === undefined ) {
1477+ // The lazy create can block on a remote environment connect
1478+ // (--environment startup). Announce the prompt and enter the waiting
1479+ // state before awaiting it: the UI keeps rendering (and the footer
1480+ // shows the connecting spinner), and the prompt is not lost when
1481+ // creation fails.
1482+ const announced =
1483+ this . state . appState . streamingPhase === 'idle' &&
1484+ ! this . deferUserMessages &&
1485+ ! this . state . appState . isCompacting ;
1486+ if ( announced ) {
1487+ this . appendTranscriptEntry ( {
1488+ id : nextTranscriptId ( ) ,
1489+ kind : 'user' ,
1490+ turnId : undefined ,
1491+ renderMode : 'plain' ,
1492+ content : text ,
1493+ imageAttachmentIds :
1494+ extraction . imageAttachmentIds . length > 0
1495+ ? [ ...extraction . imageAttachmentIds ]
1496+ : undefined ,
1497+ } ) ;
1498+ this . beginSessionRequest ( ) ;
1499+ }
14511500 session = await this . ensureSession ( ) ;
14521501 if ( session === undefined ) {
14531502 this . staging . release ( stagingLease ) ;
1503+ if ( announced ) {
1504+ // Creation failed with the error already on screen; unwind only the
1505+ // waiting state — the announced prompt stays in the transcript.
1506+ this . setAppState ( { streamingPhase : 'idle' } ) ;
1507+ this . resetLivePane ( ) ;
1508+ }
1509+ return ;
1510+ }
1511+ if ( announced ) {
1512+ if ( extraction . hasMedia ) {
1513+ this . sendMessageInternal ( session , text , {
1514+ hasMedia : true ,
1515+ parts : extraction . parts ,
1516+ imageAttachmentIds : extraction . imageAttachmentIds ,
1517+ videoAttachmentIds : extraction . videoAttachmentIds ,
1518+ lease : stagingLease ,
1519+ announced : true ,
1520+ } ) ;
1521+ } else {
1522+ this . sendMessageInternal ( session , text , { announced : true } ) ;
1523+ }
1524+ this . updateQueueDisplay ( ) ;
1525+ this . state . ui . requestRender ( ) ;
14541526 return ;
14551527 }
14561528 }
@@ -1794,14 +1866,16 @@ export class KimiTUI {
17941866 options ?. imageAttachmentIds !== undefined && options . imageAttachmentIds . length > 0
17951867 ? options . imageAttachmentIds
17961868 : undefined ;
1797- this . appendTranscriptEntry ( {
1798- id : nextTranscriptId ( ) ,
1799- kind : 'user' ,
1800- turnId : undefined ,
1801- renderMode : 'plain' ,
1802- content : input ,
1803- imageAttachmentIds,
1804- } ) ;
1869+ if ( options ?. announced !== true ) {
1870+ this . appendTranscriptEntry ( {
1871+ id : nextTranscriptId ( ) ,
1872+ kind : 'user' ,
1873+ turnId : undefined ,
1874+ renderMode : 'plain' ,
1875+ content : input ,
1876+ imageAttachmentIds,
1877+ } ) ;
1878+ }
18051879 // A goal-active steer is buffered into the running goal turn — no new
18061880 // turn.started will fire for handleTurnStarted to claim the lease — so
18071881 // bind it to that turn here. The turn context must be read BEFORE
@@ -1812,7 +1886,9 @@ export class KimiTUI {
18121886 this . state . appState . streamingPhase === 'idle' || this . state . appState . streamingPhase === 'shell'
18131887 ? undefined
18141888 : this . streamingUI . getTurnContext ( ) . turnId ;
1815- this . beginSessionRequest ( ) ;
1889+ if ( options ?. announced !== true ) {
1890+ this . beginSessionRequest ( ) ;
1891+ }
18161892
18171893 // Compression captions for pasted images are authored here — not at
18181894 // extraction — because only now is the session (and its media-originals
@@ -2213,6 +2289,63 @@ export class KimiTUI {
22132289 return this . session ;
22142290 }
22152291
2292+ /**
2293+ * `--environment <id>` startup binding: fail fast on an id the merged
2294+ * [environments] declarations do not know — the engine only rejects it at
2295+ * createSession, which the lazy startup defers to the first prompt (losing
2296+ * that prompt). Then pre-create the session in the background so the remote
2297+ * connect overlaps startup instead of blocking the first prompt.
2298+ */
2299+ private async prepareStartupEnvironment ( environmentId : string ) : Promise < void > {
2300+ let declarations : readonly WorkspaceEnvironmentDeclarationInfo [ ] | undefined ;
2301+ try {
2302+ declarations = await this . harness . listEnvironmentDeclarations ( this . state . appState . workDir ) ;
2303+ } catch {
2304+ // Declaration resolution failed (e.g. unreadable config): skip the early
2305+ // check — the background create surfaces the engine's own error.
2306+ declarations = undefined ;
2307+ }
2308+ if ( declarations !== undefined ) {
2309+ const declared = declarations . find ( ( entry ) => entry . id === environmentId ) ;
2310+ if ( declared === undefined ) {
2311+ throw new Error ( `environment "${ environmentId } " is not declared in [environments]` ) ;
2312+ }
2313+ if ( declared . defaultCwd === undefined ) {
2314+ throw new Error ( `environment "${ environmentId } " does not set defaultCwd in [environments]` ) ;
2315+ }
2316+ this . startupEnvironmentType = declared . type ;
2317+ }
2318+ void this . ensureSession ( ) ;
2319+ }
2320+
2321+ /**
2322+ * While the `--environment` startup session is being created there is no
2323+ * binding to sync from yet; a synthetic connecting slot drives the same
2324+ * footer spinner the registry-backed slot shows once refreshEnvironmentSlot
2325+ * takes over.
2326+ */
2327+ private markStartupEnvironmentConnecting ( ) : void {
2328+ const environmentId = this . options . startup . environment ;
2329+ if ( environmentId === undefined || environmentId === 'local' ) return ;
2330+ const current = this . state . appState . environment ;
2331+ if ( current ?. environmentId === environmentId && current . status === 'connecting' ) return ;
2332+ this . setAppState ( {
2333+ environment : {
2334+ environmentId,
2335+ type : this . startupEnvironmentType ?? 'command' ,
2336+ status : 'connecting' ,
2337+ } ,
2338+ } ) ;
2339+ }
2340+
2341+ private clearStartupEnvironmentConnecting ( ) : void {
2342+ const environmentId = this . options . startup . environment ;
2343+ const current = this . state . appState . environment ;
2344+ if ( environmentId === undefined || current ?. environmentId !== environmentId ) return ;
2345+ if ( current . status !== 'connecting' ) return ;
2346+ this . setAppState ( { environment : undefined } ) ;
2347+ }
2348+
22162349 /**
22172350 * Seed appState with the config defaults the v2 engine would apply at
22182351 * createSession time (model, permission, plan mode, thinking effort,
@@ -2357,22 +2490,25 @@ export class KimiTUI {
23572490 }
23582491
23592492 private async lazyCreateSession ( ) : Promise < Session | undefined > {
2493+ this . markStartupEnvironmentConnecting ( ) ;
23602494 let session : Session ;
23612495 try {
23622496 session = await this . createSessionFromCurrentState ( true ) ;
23632497 } catch ( error ) {
2498+ this . clearStartupEnvironmentConnecting ( ) ;
23642499 const msg = formatErrorMessage ( error ) ;
23652500 this . showError ( `Failed to start a session: ${ msg } ` ) ;
23662501 return undefined ;
23672502 }
2368- this . resetSessionRuntime ( ) ;
2503+ this . resetSessionRuntime ( true ) ;
23692504 await this . setSession ( session ) ;
23702505 this . setAppState ( { sessionId : session . id } ) ;
23712506 try {
23722507 await this . activateRuntime ( ) ;
23732508 await this . syncRuntimeState ( session ) ;
23742509 } catch ( error ) {
23752510 this . sessionEventHandler . startSubscription ( ) ;
2511+ this . clearStartupEnvironmentConnecting ( ) ;
23762512 const msg = formatErrorMessage ( error ) ;
23772513 this . showError ( `Post-create setup failed: ${ msg } ` ) ;
23782514 return undefined ;
@@ -2673,12 +2809,15 @@ export class KimiTUI {
26732809 this . state . terminal . setTitle ( label ) ;
26742810 }
26752811
2676- resetSessionRuntime ( ) : void {
2812+ resetSessionRuntime ( preserveQueue = false ) : void {
26772813 this . aborted = false ;
26782814 this . cacheHint . resetRuntime ( ) ;
26792815 this . surveyController . reset ( ) ;
26802816 this . streamingUI . discardPending ( ) ;
2681- this . clearQueuedMessages ( ) ;
2817+ // The lazy first creation keeps input queued while it was in flight (a
2818+ // bash command behind the announced first prompt belongs to the session
2819+ // being created); every other reset discards the old session's backlog.
2820+ if ( ! preserveQueue ) this . clearQueuedMessages ( ) ;
26822821 this . state . swarmModeEntry = undefined ;
26832822 this . streamingUI . resetToolCallState ( ) ;
26842823 this . streamingUI . resetToolUi ( ) ;
0 commit comments