Skip to content

Commit 47dcf04

Browse files
committed
Merge branch 'feat/tui-environment-background-connect-start'
2 parents 66b0958 + ea30394 commit 47dcf04

14 files changed

Lines changed: 641 additions & 16 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@moonshot-ai/kimi-code": minor
3+
---
4+
5+
Starting with `--environment` no longer freezes the interface while the remote environment connects and reports an unknown environment id immediately; subagent cards now show the environment they run in.

‎apps/kimi-code/src/tui/components/messages/agent-group.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,7 @@ function formatStats(snap: ToolCallSubagentSnapshot): string {
304304
const parts: string[] = [];
305305
if (snap.model !== undefined) parts.push(snap.model);
306306
if (snap.effort !== undefined) parts.push(snap.effort);
307+
if (snap.environment !== undefined) parts.push(`env ${snap.environment}`);
307308
parts.push(`${String(snap.toolCount)} tool${snap.toolCount === 1 ? '' : 's'}`);
308309
if (snap.elapsedSeconds !== undefined) parts.push(formatElapsed(snap.elapsedSeconds));
309310
if (snap.tokens > 0) parts.push(formatTokens(snap.tokens));

‎apps/kimi-code/src/tui/components/messages/tool-call.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,8 @@ export interface ToolCallSubagentSnapshot {
114114
readonly model?: string;
115115
/** Thinking effort, present only for concrete levels (on/off hidden). */
116116
readonly effort?: string;
117+
/** Remote environment the subagent runs in (the Agent tool's `environment` arg), absent for local. */
118+
readonly environment?: string;
117119
readonly phase: SubagentPhase | undefined;
118120
readonly toolCount: number;
119121
readonly elapsedSeconds: number | undefined;
@@ -158,6 +160,11 @@ function str(v: unknown): string {
158160
return typeof v === 'string' ? v : '';
159161
}
160162

163+
function subagentEnvironment(args: Readonly<Record<string, unknown>>): string | undefined {
164+
const raw = str(args['environment']).trim();
165+
return raw.length > 0 && raw !== 'local' ? raw : undefined;
166+
}
167+
161168
function formatSubagentContextTokens(contextTokens: number | undefined): string | undefined {
162169
if (contextTokens === undefined || contextTokens <= 0) return undefined;
163170
return `${formatTokenCount(contextTokens)} tok`;
@@ -1135,6 +1142,7 @@ export class ToolCallComponent extends Container {
11351142
agentName: this.subagentAgentName,
11361143
model: this.subagentModel,
11371144
effort: this.subagentEffort,
1145+
environment: subagentEnvironment(this.toolCall.args),
11381146
phase: derivedPhase,
11391147
toolCount: finished,
11401148
elapsedSeconds: this.getSubagentElapsedSeconds(),
@@ -2098,6 +2106,8 @@ export class ToolCallComponent extends Container {
20982106
const parts: string[] = [];
20992107
if (this.subagentModel !== undefined) parts.push(this.subagentModel);
21002108
if (this.subagentEffort !== undefined) parts.push(this.subagentEffort);
2109+
const environment = subagentEnvironment(this.toolCall.args);
2110+
if (environment !== undefined) parts.push(`env ${environment}`);
21012111
parts.push(`${String(this.subToolActivities.size)} tool${this.subToolActivities.size === 1 ? '' : 's'}`);
21022112
const elapsed = this.getSubagentElapsedSeconds();
21032113
if (elapsed !== undefined) parts.push(formatElapsed(elapsed));

‎apps/kimi-code/src/tui/kimi-tui.ts‎

Lines changed: 152 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -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';
2426
import { 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();

‎apps/kimi-code/test/tui/components/messages/agent-group.test.ts‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,55 @@ describe('AgentGroupComponent', () => {
111111
running.dispose();
112112
});
113113

114+
it('shows the environment badge in the row stats only for a non-local subagent', () => {
115+
vi.useFakeTimers();
116+
vi.setSystemTime(0);
117+
const ui = stubTui();
118+
const group = new AgentGroupComponent(ui);
119+
const remote = new ToolCallComponent(
120+
{
121+
id: 'call_agent_env',
122+
name: 'Agent',
123+
args: { description: 'inspect project', environment: 'dev-box' },
124+
},
125+
undefined,
126+
ui,
127+
);
128+
remote.onSubagentSpawned({
129+
agentId: 'sub_call_agent_env',
130+
agentName: 'explore',
131+
runInBackground: false,
132+
});
133+
startAgent(remote, 'call_agent_env', 'explore');
134+
const local = new ToolCallComponent(
135+
{
136+
id: 'call_agent_local',
137+
name: 'Agent',
138+
args: { description: 'write tests', environment: 'local' },
139+
},
140+
undefined,
141+
ui,
142+
);
143+
local.onSubagentSpawned({
144+
agentId: 'sub_call_agent_local',
145+
agentName: 'coder',
146+
runInBackground: false,
147+
});
148+
startAgent(local, 'call_agent_local', 'coder');
149+
150+
group.attach('call_agent_env', remote);
151+
group.attach('call_agent_local', local);
152+
153+
const output = renderText(group);
154+
expect(output).toContain('explore · inspect project · env dev-box · 0 tools');
155+
expect(output).toContain('coder · write tests · 0 tools');
156+
expect(output).not.toContain('env local');
157+
158+
group.dispose();
159+
remote.dispose();
160+
local.dispose();
161+
});
162+
114163
it('shows the Ctrl+B hint while agents are running and hides it once all are backgrounded', () => {
115164
vi.useFakeTimers();
116165
vi.setSystemTime(0);

0 commit comments

Comments
 (0)