SaylorTwift HF Staff commited on
Commit
5710dd0
·
verified ·
1 Parent(s): f59fbe2

Add files using upload-large-folder tool

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. apps/kimi-inspect/src/App.tsx +166 -0
  2. apps/kimi-inspect/src/activity/di.ts +53 -0
  3. apps/kimi-inspect/src/activity/store.test.ts +227 -0
  4. apps/kimi-inspect/src/activity/store.ts +160 -0
  5. apps/kimi-inspect/src/activity/useSessionActivity.ts +52 -0
  6. apps/kimi-inspect/src/activity/ws.ts +308 -0
  7. apps/kimi-inspect/src/audit/audit.test.ts +266 -0
  8. apps/kimi-inspect/src/audit/diff.ts +122 -0
  9. apps/kimi-inspect/src/audit/serialize.ts +43 -0
  10. apps/kimi-inspect/src/audit/trail.ts +198 -0
  11. apps/kimi-inspect/src/audit/truncate.ts +14 -0
  12. apps/kimi-inspect/src/channel/channel.test.ts +199 -0
  13. apps/kimi-inspect/src/channel/channel.ts +45 -0
  14. apps/kimi-inspect/src/channel/channels.ts +120 -0
  15. apps/kimi-inspect/src/channel/client.ts +81 -0
  16. apps/kimi-inspect/src/channel/errors.ts +16 -0
  17. apps/kimi-inspect/src/channel/index.ts +7 -0
  18. apps/kimi-inspect/src/channel/proxy.ts +21 -0
  19. apps/kimi-inspect/src/channel/proxyChannel.ts +74 -0
  20. apps/kimi-inspect/src/channel/wsLike.ts +20 -0
  21. apps/kimi-inspect/src/components/AppServicesView.tsx +27 -0
  22. apps/kimi-inspect/src/components/BashParserView.tsx +279 -0
  23. apps/kimi-inspect/src/components/ChatSearchBar.tsx +147 -0
  24. apps/kimi-inspect/src/components/ChatView.tsx +1231 -0
  25. apps/kimi-inspect/src/components/DiInspectionView.tsx +635 -0
  26. apps/kimi-inspect/src/components/FsSuggestView.tsx +256 -0
  27. apps/kimi-inspect/src/components/Inspector.tsx +308 -0
  28. apps/kimi-inspect/src/components/InteractionsCard.tsx +209 -0
  29. apps/kimi-inspect/src/components/ModelCatalogView.tsx +413 -0
  30. apps/kimi-inspect/src/components/NavRail.tsx +152 -0
  31. apps/kimi-inspect/src/components/RightPanel.tsx +105 -0
  32. apps/kimi-inspect/src/components/SearchView.tsx +254 -0
  33. apps/kimi-inspect/src/components/ServerSwitcher.tsx +48 -0
  34. apps/kimi-inspect/src/components/ServicePanels.tsx +805 -0
  35. apps/kimi-inspect/src/components/SessionPane.tsx +100 -0
  36. apps/kimi-inspect/src/components/Sidebar.tsx +602 -0
  37. apps/kimi-inspect/src/components/StateCard.tsx +117 -0
  38. apps/kimi-inspect/src/components/WorkspaceDirBrowser.tsx +211 -0
  39. apps/kimi-inspect/src/components/WorkspaceServicesView.tsx +138 -0
  40. apps/kimi-inspect/src/components/audit/AuditPanel.tsx +188 -0
  41. apps/kimi-inspect/src/components/audit/StateTree.test.tsx +161 -0
  42. apps/kimi-inspect/src/components/audit/StateTree.tsx +259 -0
  43. apps/kimi-inspect/src/components/di/DiEventsPanel.tsx +157 -0
  44. apps/kimi-inspect/src/components/di/DiGraphPanel.tsx +374 -0
  45. apps/kimi-inspect/src/components/methodArgs.test.ts +124 -0
  46. apps/kimi-inspect/src/components/methodArgs.ts +198 -0
  47. apps/kimi-inspect/src/connection.tsx +364 -0
  48. apps/kimi-inspect/src/fs/api.test.ts +100 -0
  49. apps/kimi-inspect/src/fs/api.ts +96 -0
  50. apps/kimi-inspect/src/index.css +33 -0
apps/kimi-inspect/src/App.tsx ADDED
@@ -0,0 +1,166 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * App shell — selection state and session resume. There is no live event
3
+ * push anymore: the v2 socket (`/api/v2/ws`) that fed the core/session/agent
4
+ * event streams was removed server-side, so Service panels and the pending
5
+ * interactions card fetch on demand and the sidebar polls.
6
+ * Layout: header / icon rail / view. The `chat` view is a strip of the
7
+ * left sidebar (a workspace → session tree), the chat column, and the
8
+ * right dock (`RightPanel`) merging the transcript audit, the agent
9
+ * inspector, and the session pane under Audit / Agent / State / Session
10
+ * tabs;
11
+ * the `models` view is the full-width model catalog; the `services` view is
12
+ * the full-width app-scope Service reflection (`AppServicesView`); the
13
+ * `workspace` view is the workspace-scope counterpart
14
+ * (`WorkspaceServicesView`, with a workspace picker on top); the
15
+ * `bash` view is the full-width `IBashParserService` playground
16
+ * (`BashParserView`); the `di` view is the engine's Service × Effect × DI
17
+ * debug surface (`DiInspectionView`); the `search` view is the full-width
18
+ * global message search (`SearchView`) whose hits navigate back into the
19
+ * chat timeline.
20
+ */
21
+
22
+ import { ISessionManager } from '@moonshot-ai/agent-core-v2/app/sessionManager/sessionManager';
23
+ import { useEffect, useState } from 'react';
24
+
25
+ import type { AuditTrail } from './audit/trail';
26
+ import { AppServicesView } from './components/AppServicesView';
27
+ import { BashParserView } from './components/BashParserView';
28
+ import { ChatView, type ChatJump } from './components/ChatView';
29
+ import { DiInspectionView } from './components/DiInspectionView';
30
+ import { FsSuggestView } from './components/FsSuggestView';
31
+ import { ModelCatalogView } from './components/ModelCatalogView';
32
+ import { NavRail, type AppView } from './components/NavRail';
33
+ import { RightPanel } from './components/RightPanel';
34
+ import { SearchView } from './components/SearchView';
35
+ import { ServerSwitcher } from './components/ServerSwitcher';
36
+ import { Sidebar } from './components/Sidebar';
37
+ import { WorkspaceServicesView } from './components/WorkspaceServicesView';
38
+ import { useConnection } from './connection';
39
+ import type { SearchHit } from './search/api';
40
+ import { errorMessage } from './ui';
41
+
42
+ export function App() {
43
+ const { klient, baseUrl, disconnect } = useConnection();
44
+ const [sessionId, setSessionId] = useState<string | null>(null);
45
+ const [agentId, setAgentId] = useState('main');
46
+ const [view, setView] = useState<AppView>('chat');
47
+ const [ready, setReady] = useState(false);
48
+ const [resumeError, setResumeError] = useState<unknown>(null);
49
+ /** Audit trail of the chat view's transcript channel, rendered in the right dock. */
50
+ const [trail, setTrail] = useState<AuditTrail | null>(null);
51
+ /** Pending chat navigation requested from another view (search result click). */
52
+ const [jump, setJump] = useState<ChatJump | null>(null);
53
+
54
+ // Resume (materialize) the session on the server when it is selected, so
55
+ // session / agent scoped Services become reachable. Session lifecycle lives
56
+ // on the workspace handler (Workspace scope): the index yields the
57
+ // session's workspaceId, then the handler resumes it.
58
+ useEffect(() => {
59
+ if (sessionId === null) return;
60
+ let cancelled = false;
61
+ setReady(false);
62
+ setResumeError(null);
63
+ klient
64
+ .core(ISessionManager)
65
+ .resume(sessionId)
66
+ .then((session) => {
67
+ if (session === undefined) throw new Error(`session ${sessionId} does not exist`);
68
+ })
69
+ .then(() => {
70
+ if (!cancelled) setReady(true);
71
+ })
72
+ .catch((error: unknown) => {
73
+ if (!cancelled) setResumeError(error);
74
+ });
75
+ return () => {
76
+ cancelled = true;
77
+ };
78
+ }, [klient, sessionId]);
79
+
80
+ // Switching servers invalidates every session/agent selection: sessions
81
+ // belong to the server they were listed from.
82
+ useEffect(() => {
83
+ setSessionId(null);
84
+ setAgentId('main');
85
+ setJump(null);
86
+ }, [baseUrl]);
87
+
88
+ // A search hit opens the chat view at its session / agent / turn / step.
89
+ // Title hits belong to the session (agent '') and carry no turn: switch
90
+ // over without a scroll target.
91
+ const openSearchHit = (hit: SearchHit): void => {
92
+ setSessionId(hit.sessionId);
93
+ setAgentId(hit.agentId === '' ? 'main' : hit.agentId);
94
+ setView('chat');
95
+ setJump({
96
+ turnId: hit.turn === undefined ? undefined : `t${hit.turn}`,
97
+ stepId: hit.stepId,
98
+ nonce: Date.now(),
99
+ });
100
+ };
101
+
102
+ return (
103
+ <div className="flex h-screen flex-col">
104
+ <header className="flex items-center gap-3 border-b border-neutral-800 px-4 py-1.5">
105
+ <span className="text-[12px] font-bold tracking-widest text-neutral-200">KIMI INSPECT</span>
106
+ <ServerSwitcher />
107
+ <div className="flex-1" />
108
+ <button
109
+ className="text-[11px] text-neutral-500 hover:text-neutral-300"
110
+ onClick={disconnect}
111
+ >
112
+ Disconnect
113
+ </button>
114
+ </header>
115
+ <div className="flex min-h-0 flex-1">
116
+ <NavRail view={view} onChange={setView} />
117
+ {view === 'services' ? (
118
+ <AppServicesView />
119
+ ) : view === 'workspace' ? (
120
+ <WorkspaceServicesView />
121
+ ) : view === 'suggest' ? (
122
+ <FsSuggestView />
123
+ ) : view === 'bash' ? (
124
+ <BashParserView />
125
+ ) : view === 'di' ? (
126
+ <DiInspectionView />
127
+ ) : view === 'models' ? (
128
+ <ModelCatalogView
129
+ onOpenSession={(id) => {
130
+ setSessionId(id);
131
+ setView('chat');
132
+ }}
133
+ />
134
+ ) : view === 'search' ? (
135
+ <SearchView onOpenResult={openSearchHit} />
136
+ ) : (
137
+ <>
138
+ <Sidebar activeSessionId={sessionId} onSelectSession={setSessionId} />
139
+ {resumeError !== null ? (
140
+ <div className="flex flex-1 items-center justify-center p-6 text-center text-[12px] text-red-400">
141
+ Failed to open session: {errorMessage(resumeError)}
142
+ </div>
143
+ ) : (
144
+ <ChatView
145
+ sessionId={sessionId}
146
+ agentId={agentId}
147
+ ready={ready}
148
+ onTrailChange={setTrail}
149
+ jump={jump}
150
+ onJumpHandled={() => setJump(null)}
151
+ onOpenSearchHit={openSearchHit}
152
+ />
153
+ )}
154
+ <RightPanel
155
+ sessionId={sessionId}
156
+ agentId={agentId}
157
+ onAgentChange={setAgentId}
158
+ ready={ready}
159
+ trail={trail}
160
+ />
161
+ </>
162
+ )}
163
+ </div>
164
+ </div>
165
+ );
166
+ }
apps/kimi-inspect/src/activity/di.ts ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * DI debug feed — a dedicated global-events socket for the DI view. The
3
+ * session activity hub (`useSessionActivities`) lives with the chat Sidebar,
4
+ * which unmounts when the DI view is active, so the DI view owns its own
5
+ * `GlobalEventsWs` (only one of the two is ever connected at a time).
6
+ *
7
+ * Every `event.di.unit_changed` frame invalidates the `['di']` query prefix
8
+ * (the same pattern as `event.session.created` invalidating `['sessions']`),
9
+ * so all DI panels refetch on unit transitions instead of waiting out their
10
+ * poll interval. Bursts (a cascade flipping many units at once) are coalesced
11
+ * with a short trailing throttle; a reconnect invalidates immediately, since
12
+ * live transitions were missed while the socket was down.
13
+ */
14
+
15
+ import { useQueryClient } from '@tanstack/react-query';
16
+ import { useEffect } from 'react';
17
+
18
+ import { useConnection } from '../connection';
19
+ import { GlobalEventsWs } from './ws';
20
+
21
+ const INVALIDATE_THROTTLE_MS = 250;
22
+
23
+ export function useDiQueryInvalidation(): void {
24
+ const { baseUrl, config } = useConnection();
25
+ const queryClient = useQueryClient();
26
+ const token = config.token.trim();
27
+
28
+ useEffect(() => {
29
+ let timer: ReturnType<typeof setTimeout> | undefined;
30
+ const invalidate = () => {
31
+ if (timer !== undefined) return;
32
+ timer = setTimeout(() => {
33
+ timer = undefined;
34
+ void queryClient.invalidateQueries({ queryKey: ['di'] });
35
+ }, INVALIDATE_THROTTLE_MS);
36
+ };
37
+ const ws = new GlobalEventsWs({
38
+ url: baseUrl,
39
+ token: token === '' ? undefined : token,
40
+ handlers: {
41
+ onWorkChanged: () => {},
42
+ onSessionCreated: () => {},
43
+ onMetaUpdated: () => {},
44
+ onDiUnitChanged: invalidate,
45
+ onReconnected: invalidate,
46
+ },
47
+ });
48
+ return () => {
49
+ if (timer !== undefined) clearTimeout(timer);
50
+ ws.close();
51
+ };
52
+ }, [baseUrl, token, queryClient]);
53
+ }
apps/kimi-inspect/src/activity/store.test.ts ADDED
@@ -0,0 +1,227 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, expect, it, vi } from 'vitest';
2
+
3
+ import type { WsLike, WsLikeCtor } from '../channel/wsLike';
4
+ import { SessionActivityHub, SessionActivityStore, type SessionWorkFacts } from './store';
5
+
6
+ function facts(partial: Partial<SessionWorkFacts> = {}): SessionWorkFacts {
7
+ return {
8
+ busy: false,
9
+ mainTurnActive: false,
10
+ pendingInteraction: 'none',
11
+ lastTurnReason: undefined,
12
+ ...partial,
13
+ };
14
+ }
15
+
16
+ class FakeWs implements WsLike {
17
+ static readonly OPEN = 1;
18
+ readyState = 1;
19
+ sent: string[] = [];
20
+ closed = false;
21
+ private readonly listeners = new Map<string, Array<(event: never) => void>>();
22
+
23
+ send(data: string): void {
24
+ this.sent.push(data);
25
+ }
26
+ close(): void {
27
+ this.closed = true;
28
+ }
29
+ addEventListener(type: string, listener: (event: never) => void): void {
30
+ const list = this.listeners.get(type) ?? [];
31
+ list.push(listener);
32
+ this.listeners.set(type, list);
33
+ }
34
+ emit(type: 'open' | 'message' | 'close' | 'error', event?: unknown): void {
35
+ for (const listener of this.listeners.get(type) ?? []) listener(event as never);
36
+ }
37
+ emitFrame(frame: Record<string, unknown>): void {
38
+ this.emit('message', { data: JSON.stringify(frame) });
39
+ }
40
+ }
41
+
42
+ function makeFakeWsCtor(): { ctor: WsLikeCtor; instances: FakeWs[] } {
43
+ const instances: FakeWs[] = [];
44
+ const ctor = class {
45
+ static readonly OPEN = 1;
46
+ constructor(_url: string, _protocols?: string | string[]) {
47
+ const ws = new FakeWs();
48
+ instances.push(ws);
49
+ return ws;
50
+ }
51
+ } as unknown as WsLikeCtor;
52
+ return { ctor, instances };
53
+ }
54
+
55
+ function seedFetch(items: Record<string, unknown>[]): typeof fetch {
56
+ return vi.fn(async () => ({
57
+ json: async () => ({ code: 0, data: { items, has_more: false } }),
58
+ })) as unknown as typeof fetch;
59
+ }
60
+
61
+ describe('SessionActivityStore', () => {
62
+ it('applies work facts and notifies with a version bump', () => {
63
+ const store = new SessionActivityStore();
64
+ const listener = vi.fn();
65
+ store.subscribe(listener);
66
+
67
+ store.applyWorkChanged('s1', facts({ busy: true, mainTurnActive: true }));
68
+
69
+ expect(store.get('s1')).toEqual(facts({ busy: true, mainTurnActive: true }));
70
+ expect(store.getVersion()).toBe(1);
71
+ expect(listener).toHaveBeenCalledTimes(1);
72
+ });
73
+
74
+ it('ignores identical facts (no bump, no notify)', () => {
75
+ const store = new SessionActivityStore();
76
+ const listener = vi.fn();
77
+ store.applyWorkChanged('s1', facts({ busy: true }));
78
+ store.subscribe(listener);
79
+
80
+ store.applyWorkChanged('s1', facts({ busy: true }));
81
+
82
+ expect(store.getVersion()).toBe(1);
83
+ expect(listener).not.toHaveBeenCalled();
84
+ });
85
+
86
+ it('seed replaces the whole map', () => {
87
+ const store = new SessionActivityStore();
88
+ store.applyWorkChanged('stale', facts({ busy: true }));
89
+
90
+ store.seed([['s1', facts({ pendingInteraction: 'approval' })]]);
91
+
92
+ expect(store.get('stale')).toBeUndefined();
93
+ expect(store.get('s1')?.pendingInteraction).toBe('approval');
94
+ });
95
+ });
96
+
97
+ describe('SessionActivityHub', () => {
98
+ it('seeds the store from the REST session list when the socket opens', async () => {
99
+ const { ctor, instances } = makeFakeWsCtor();
100
+ const hub = new SessionActivityHub({
101
+ url: 'http://127.0.0.1:58627',
102
+ onListChanged: () => {},
103
+ WebSocketImpl: ctor,
104
+ fetchImpl: seedFetch([
105
+ { id: 's1', busy: true, main_turn_active: true, pending_interaction: 'none' },
106
+ { id: 's2', busy: false, main_turn_active: false, pending_interaction: 'approval' },
107
+ ]),
108
+ });
109
+
110
+ instances[0]!.emit('open');
111
+ await vi.waitFor(() => expect(hub.store.get('s1')).toBeDefined());
112
+
113
+ expect(hub.store.get('s1')).toEqual(facts({ busy: true, mainTurnActive: true }));
114
+ expect(hub.store.get('s2')?.pendingInteraction).toBe('approval');
115
+ // The hello goes out with no subscriptions — global facts flow regardless.
116
+ const hello = JSON.parse(instances[0]!.sent[0]!) as {
117
+ type: string;
118
+ payload: { subscriptions: string[] };
119
+ };
120
+ expect(hello.type).toBe('client_hello');
121
+ expect(hello.payload.subscriptions).toEqual([]);
122
+ hub.close();
123
+ });
124
+
125
+ it('applies live work_changed frames by session id', () => {
126
+ const { ctor, instances } = makeFakeWsCtor();
127
+ const hub = new SessionActivityHub({
128
+ url: 'http://127.0.0.1:58627',
129
+ onListChanged: () => {},
130
+ WebSocketImpl: ctor,
131
+ fetchImpl: seedFetch([]),
132
+ });
133
+ instances[0]!.emit('open');
134
+
135
+ instances[0]!.emitFrame({
136
+ type: 'event.session.work_changed',
137
+ session_id: 's1',
138
+ payload: {
139
+ type: 'event.session.work_changed',
140
+ busy: true,
141
+ main_turn_active: true,
142
+ pending_interaction: 'question',
143
+ last_turn_reason: null,
144
+ },
145
+ });
146
+
147
+ expect(hub.store.get('s1')).toEqual(
148
+ facts({ busy: true, mainTurnActive: true, pendingInteraction: 'question' }),
149
+ );
150
+ hub.close();
151
+ });
152
+
153
+ it('forwards created and meta updates as list-level signals', () => {
154
+ const { ctor, instances } = makeFakeWsCtor();
155
+ const onListChanged = vi.fn();
156
+ const hub = new SessionActivityHub({
157
+ url: 'http://127.0.0.1:58627',
158
+ onListChanged,
159
+ WebSocketImpl: ctor,
160
+ fetchImpl: seedFetch([]),
161
+ });
162
+ instances[0]!.emit('open');
163
+
164
+ instances[0]!.emitFrame({ type: 'event.session.created', session_id: 's1', payload: {} });
165
+ instances[0]!.emitFrame({ type: 'session.meta.updated', session_id: 's1', payload: {} });
166
+ // Agent-grained frames are ignored even if they somehow arrive.
167
+ instances[0]!.emitFrame({ type: 'turn.started', session_id: 's1', payload: {} });
168
+
169
+ expect(onListChanged).toHaveBeenCalledTimes(2);
170
+ expect(hub.store.get('s1')).toBeUndefined();
171
+ hub.close();
172
+ });
173
+
174
+ it('forwards archived and workspace frames as list-level signals and drops archived facts', () => {
175
+ const { ctor, instances } = makeFakeWsCtor();
176
+ const onListChanged = vi.fn();
177
+ const hub = new SessionActivityHub({
178
+ url: 'http://127.0.0.1:58627',
179
+ onListChanged,
180
+ WebSocketImpl: ctor,
181
+ fetchImpl: seedFetch([]),
182
+ });
183
+ instances[0]!.emit('open');
184
+
185
+ instances[0]!.emitFrame({
186
+ type: 'event.session.work_changed',
187
+ session_id: 's1',
188
+ payload: { type: 'event.session.work_changed', busy: true },
189
+ });
190
+ expect(hub.store.get('s1')).toBeDefined();
191
+
192
+ // Global-dispatched frames carry the __global__ watermark; the real
193
+ // session id rides in the payload.
194
+ instances[0]!.emitFrame({
195
+ type: 'event.session.archived',
196
+ session_id: '__global__',
197
+ payload: { type: 'event.session.archived', sessionId: 's1', workspace_id: 'wd_1' },
198
+ });
199
+ expect(hub.store.get('s1')).toBeUndefined();
200
+ expect(onListChanged).toHaveBeenCalledTimes(1);
201
+
202
+ instances[0]!.emitFrame({
203
+ type: 'event.session.work_changed',
204
+ session_id: 's2',
205
+ payload: { type: 'event.session.work_changed', busy: true },
206
+ });
207
+ expect(hub.store.get('s2')).toBeDefined();
208
+
209
+ instances[0]!.emitFrame({
210
+ type: 'event.session.deleted',
211
+ session_id: '__global__',
212
+ payload: { type: 'event.session.deleted', sessionId: 's2', workspace_id: 'wd_1' },
213
+ });
214
+ expect(hub.store.get('s2')).toBeUndefined();
215
+ expect(onListChanged).toHaveBeenCalledTimes(2);
216
+
217
+ for (const type of [
218
+ 'event.workspace.created',
219
+ 'event.workspace.updated',
220
+ 'event.workspace.deleted',
221
+ ]) {
222
+ instances[0]!.emitFrame({ type, session_id: '__global__', payload: {} });
223
+ }
224
+ expect(onListChanged).toHaveBeenCalledTimes(5);
225
+ hub.close();
226
+ });
227
+ });
apps/kimi-inspect/src/activity/store.ts ADDED
@@ -0,0 +1,160 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Session activity hub — owns the global-events socket and the per-session
3
+ * coarse activity map behind the Sidebar's status badges.
4
+ *
5
+ * Two data sources converge into one store: the initial / reconnect
6
+ * baseline comes from a single `GET /api/v1/sessions` page (every wire
7
+ * session carries `busy` / `main_turn_active` / `pending_interaction` /
8
+ * `last_turn_reason`), and live updates arrive as
9
+ * `event.session.work_changed` frames over the global WS channel (no
10
+ * subscription needed server-side). List-level facts (session created /
11
+ * retitled) are forwarded to the consumer as `onListChanged` so the
12
+ * react-query session list invalidates instead of waiting out its slow poll.
13
+ * The store is a plain subscribe/version store so React binds through
14
+ * `useSyncExternalStore`.
15
+ */
16
+
17
+ import type { WsLikeCtor } from '../channel/wsLike';
18
+ import { GlobalEventsWs, type SessionWorkFacts } from './ws';
19
+
20
+ export type { SessionWorkFacts };
21
+
22
+ export class SessionActivityStore {
23
+ private activities = new Map<string, SessionWorkFacts>();
24
+ private readonly listeners = new Set<() => void>();
25
+ private version = 0;
26
+
27
+ get(sessionId: string): SessionWorkFacts | undefined {
28
+ return this.activities.get(sessionId);
29
+ }
30
+
31
+ getVersion(): number {
32
+ return this.version;
33
+ }
34
+
35
+ subscribe(listener: () => void): () => void {
36
+ this.listeners.add(listener);
37
+ return () => this.listeners.delete(listener);
38
+ }
39
+
40
+ applyWorkChanged(sessionId: string, facts: SessionWorkFacts): void {
41
+ const previous = this.activities.get(sessionId);
42
+ if (
43
+ previous !== undefined &&
44
+ previous.busy === facts.busy &&
45
+ previous.mainTurnActive === facts.mainTurnActive &&
46
+ previous.pendingInteraction === facts.pendingInteraction &&
47
+ previous.lastTurnReason === facts.lastTurnReason
48
+ ) {
49
+ return;
50
+ }
51
+ this.activities.set(sessionId, facts);
52
+ this.bump();
53
+ }
54
+
55
+ /** Replace the whole map with a REST baseline (initial load / re-seed). */
56
+ seed(entries: Iterable<readonly [string, SessionWorkFacts]>): void {
57
+ this.activities = new Map(entries);
58
+ this.bump();
59
+ }
60
+
61
+ /** Drop one session's live facts (e.g. it was archived — no further
62
+ * work_changed frames will arrive to correct a stale badge). */
63
+ remove(sessionId: string): void {
64
+ if (this.activities.delete(sessionId)) this.bump();
65
+ }
66
+
67
+ private bump(): void {
68
+ this.version += 1;
69
+ for (const listener of this.listeners) listener();
70
+ }
71
+ }
72
+
73
+ export interface SessionActivityHubOptions {
74
+ /** Server base URL (`http(s)://host:port`). */
75
+ readonly url: string;
76
+ readonly token?: string | undefined;
77
+ /** List-level signal (session created / retitled) — invalidate the list. */
78
+ readonly onListChanged: () => void;
79
+ readonly WebSocketImpl?: WsLikeCtor;
80
+ readonly fetchImpl?: typeof fetch;
81
+ }
82
+
83
+ export class SessionActivityHub {
84
+ readonly store = new SessionActivityStore();
85
+ private readonly ws: GlobalEventsWs;
86
+ private readonly baseUrl: string;
87
+ private readonly token?: string;
88
+ private readonly fetchImpl: typeof fetch;
89
+
90
+ constructor(opts: SessionActivityHubOptions) {
91
+ this.baseUrl = opts.url.replace(/\/$/, '');
92
+ this.token = opts.token;
93
+ // Bind the default: `this.fetchImpl(...)` is a member call, and the
94
+ // browser's `fetch` throws Illegal invocation when its receiver is not
95
+ // the global object (Node's undici fetch does not care).
96
+ this.fetchImpl = opts.fetchImpl ?? fetch.bind(globalThis);
97
+ this.ws = new GlobalEventsWs({
98
+ url: opts.url,
99
+ token: opts.token,
100
+ WebSocketImpl: opts.WebSocketImpl,
101
+ handlers: {
102
+ onWorkChanged: (sessionId, facts) => this.store.applyWorkChanged(sessionId, facts),
103
+ onSessionCreated: () => opts.onListChanged(),
104
+ onMetaUpdated: () => opts.onListChanged(),
105
+ onSessionArchived: (sessionId) => {
106
+ this.store.remove(sessionId);
107
+ opts.onListChanged();
108
+ },
109
+ onSessionDeleted: (sessionId) => {
110
+ this.store.remove(sessionId);
111
+ opts.onListChanged();
112
+ },
113
+ onWorkspaceChanged: () => opts.onListChanged(),
114
+ onReconnected: () => void this.seed(),
115
+ },
116
+ });
117
+ }
118
+
119
+ close(): void {
120
+ this.ws.close();
121
+ }
122
+
123
+ private async seed(): Promise<void> {
124
+ const headers: Record<string, string> = {};
125
+ if (this.token !== undefined && this.token.length > 0) {
126
+ headers['authorization'] = `Bearer ${this.token}`;
127
+ }
128
+ try {
129
+ const res = await this.fetchImpl(`${this.baseUrl}/api/v1/sessions`, { headers });
130
+ const envelope = (await res.json()) as {
131
+ code: number;
132
+ data?: { items?: Record<string, unknown>[] };
133
+ };
134
+ if (envelope.code !== 0 || envelope.data?.items === undefined) return;
135
+ const entries: [string, SessionWorkFacts][] = [];
136
+ for (const item of envelope.data.items) {
137
+ const id = item['id'];
138
+ if (typeof id !== 'string' || typeof item['busy'] !== 'boolean') continue;
139
+ const pending = item['pending_interaction'];
140
+ const reason = item['last_turn_reason'];
141
+ entries.push([
142
+ id,
143
+ {
144
+ busy: item['busy'],
145
+ mainTurnActive: item['main_turn_active'] === true,
146
+ pendingInteraction: pending === 'approval' || pending === 'question' ? pending : 'none',
147
+ lastTurnReason:
148
+ reason === 'completed' || reason === 'cancelled' || reason === 'failed'
149
+ ? reason
150
+ : undefined,
151
+ },
152
+ ]);
153
+ }
154
+ this.store.seed(entries);
155
+ } catch {
156
+ // Seed is best-effort: live frames keep flowing, and the next reconnect
157
+ // re-seeds. A dead server surfaces through the connection layer anyway.
158
+ }
159
+ }
160
+ }
apps/kimi-inspect/src/activity/useSessionActivity.ts ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * React binding for the session activity hub: one hub per (server, token),
3
+ * torn down on server switch or unmount. Consumers read per-session coarse
4
+ * activity (`get(sessionId)`) and re-render on every store bump; list-level
5
+ * signals invalidate the `['sessions']` / `['v2-sessions']` react-query lists
6
+ * directly.
7
+ *
8
+ * The hub is created inside `useEffect` (not `useMemo`): under StrictMode
9
+ * the mount → cleanup → re-mount cycle runs the cleanup of the FIRST mount,
10
+ * and a memo-created hub would stay closed for the rest of the page's life.
11
+ */
12
+
13
+ import { useQueryClient } from '@tanstack/react-query';
14
+ import { useEffect, useState, useSyncExternalStore } from 'react';
15
+
16
+ import { useConnection } from '../connection';
17
+ import { SessionActivityHub, SessionActivityStore, type SessionWorkFacts } from './store';
18
+
19
+ const EMPTY_STORE = new SessionActivityStore();
20
+
21
+ export function useSessionActivities(): {
22
+ get(sessionId: string): SessionWorkFacts | undefined;
23
+ } {
24
+ const { baseUrl, config } = useConnection();
25
+ const queryClient = useQueryClient();
26
+ const token = config.token.trim();
27
+ const [hub, setHub] = useState<SessionActivityHub | null>(null);
28
+
29
+ useEffect(() => {
30
+ const created = new SessionActivityHub({
31
+ url: baseUrl,
32
+ token: token === '' ? undefined : token,
33
+ onListChanged: () => {
34
+ void queryClient.invalidateQueries({ queryKey: ['sessions'] });
35
+ void queryClient.invalidateQueries({ queryKey: ['v2-sessions'] });
36
+ void queryClient.invalidateQueries({ queryKey: ['workspaces'] });
37
+ },
38
+ });
39
+ setHub(created);
40
+ return () => {
41
+ setHub(null);
42
+ created.close();
43
+ };
44
+ }, [baseUrl, token, queryClient]);
45
+
46
+ const store = hub?.store ?? EMPTY_STORE;
47
+ useSyncExternalStore(
48
+ (listener) => store.subscribe(listener),
49
+ () => store.getVersion(),
50
+ );
51
+ return store;
52
+ }
apps/kimi-inspect/src/activity/ws.ts ADDED
@@ -0,0 +1,308 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Minimal `/api/v1/ws` client for GLOBAL session facts — no subscriptions.
3
+ *
4
+ * The server pushes every global event (`event.session.*` /
5
+ * `session.meta.updated` / `event.workspace.*` / `event.config.*`) to every
6
+ * established connection, so this client subscribes to nothing: it sends a
7
+ * `client_hello` with an empty subscription list (etiquette only — the
8
+ * delivery set does not depend on it) and dispatches the coarse per-session
9
+ * facts to the consumer:
10
+ *
11
+ * - `event.session.work_changed` → `{busy, main_turn_active,
12
+ * pending_interaction, last_turn_reason}` for one session;
13
+ * - `event.session.created` / `session.meta.updated` → list-level signals
14
+ * (a session appeared / retitled), forwarded for list invalidation;
15
+ * - `event.session.archived` (live or cold) / `event.workspace.*` →
16
+ * list-level signals, forwarded for list invalidation;
17
+ * - `event.di.unit_changed` → one DI unit state transition of the engine's
18
+ * scope tree (the debug-surface feed), forwarded for `['di']`
19
+ * invalidation. Global like the rest: it carries the `__global__`
20
+ * session watermark and fans out to every connection.
21
+ *
22
+ * Session/agent-grained events never arrive here (they stay subscribe-gated
23
+ * server-side); the transcript chat channel has its own socket. Global
24
+ * frames are live-only — a drop loses whatever fired meanwhile, so the
25
+ * consumer answers `onReconnected` with a REST re-seed.
26
+ *
27
+ * The bearer token is presented at the upgrade through the
28
+ * `kimi-code.bearer.<token>` subprotocol (the only credential channel a
29
+ * browser WebSocket has).
30
+ */
31
+
32
+ import type { WsLike, WsLikeCtor } from '../channel/wsLike';
33
+
34
+ export type SessionPendingInteraction = 'none' | 'approval' | 'question';
35
+ export type SessionTurnOutcome = 'completed' | 'cancelled' | 'failed';
36
+
37
+ export interface SessionWorkFacts {
38
+ readonly busy: boolean;
39
+ readonly mainTurnActive: boolean;
40
+ readonly pendingInteraction: SessionPendingInteraction;
41
+ readonly lastTurnReason?: SessionTurnOutcome | undefined;
42
+ }
43
+
44
+ export type DiUnitState = 'Pending' | 'Activating' | 'Active' | 'Unloading' | 'Failed';
45
+
46
+ /** Wire payload of the `event.di.unit_changed` global event. */
47
+ export interface DiUnitChangedPayload {
48
+ /** Scope path of the container owning the unit (`app` / `app/workspace:<id>` / …). */
49
+ readonly scope: string;
50
+ readonly token: string;
51
+ readonly state: DiUnitState;
52
+ /** Serialized sticky failure, present only on a Failed transition. */
53
+ readonly error?: string | undefined;
54
+ }
55
+
56
+ export interface GlobalEventsWsHandlers {
57
+ /** Coarse work-fact tuple for one session changed. */
58
+ onWorkChanged: (sessionId: string, facts: SessionWorkFacts) => void;
59
+ /** A session was created (list-level signal). */
60
+ onSessionCreated: (sessionId: string) => void;
61
+ /** A session's title/patch changed (list-level signal). */
62
+ onMetaUpdated: (sessionId: string) => void;
63
+ /** A session was archived, live or cold (list-level signal). The envelope
64
+ * carries the `__global__` watermark; the real session id rides in the
65
+ * payload. */
66
+ onSessionArchived?: ((sessionId: string) => void) | undefined;
67
+ /** A session was permanently deleted (list-level signal). Same envelope
68
+ * shape as `event.session.archived`: the real session id rides in the
69
+ * payload. */
70
+ onSessionDeleted?: (sessionId: string) => void;
71
+ /** A workspace was created / updated / deleted (list-level signal). */
72
+ onWorkspaceChanged?: (() => void) | undefined;
73
+ /** A DI unit of the engine's scope tree changed state (debug feed). */
74
+ onDiUnitChanged?: ((payload: DiUnitChangedPayload) => void) | undefined;
75
+ /** Socket established (initial connect and every reconnect) — the consumer
76
+ * answers with a REST re-seed, since live facts are missed while down. */
77
+ onReconnected: () => void;
78
+ }
79
+
80
+ export interface GlobalEventsWsOptions {
81
+ /** Server base URL (`http(s)://host:port`) or a full `ws(s)://…/api/v1/ws` URL. */
82
+ readonly url: string;
83
+ readonly token?: string | undefined;
84
+ readonly handlers: GlobalEventsWsHandlers;
85
+ /** WebSocket implementation; defaults to the global `WebSocket`. */
86
+ readonly WebSocketImpl?: WsLikeCtor;
87
+ /** Base delay (ms) for the reconnect backoff. Default `500`. */
88
+ readonly reconnectDelayMs?: number;
89
+ }
90
+
91
+ interface ServerFrame {
92
+ readonly type: string;
93
+ readonly id?: string;
94
+ readonly session_id?: string;
95
+ readonly payload?: unknown;
96
+ }
97
+
98
+ const WS_BEARER_PROTOCOL_PREFIX = 'kimi-code.bearer.';
99
+
100
+ export class GlobalEventsWs {
101
+ private readonly wsUrl: string;
102
+ private readonly token?: string;
103
+ private readonly handlers: GlobalEventsWsHandlers;
104
+ private readonly WsCtor: WsLikeCtor;
105
+ private readonly reconnectDelayMs: number;
106
+
107
+ private ws: WsLike | undefined;
108
+ private manualClose = false;
109
+ private reconnectAttempt = 0;
110
+ private reconnectTimer: ReturnType<typeof setTimeout> | undefined;
111
+
112
+ constructor(opts: GlobalEventsWsOptions) {
113
+ this.wsUrl = toWsUrl(opts.url);
114
+ this.token = opts.token;
115
+ this.handlers = opts.handlers;
116
+ const ctor = opts.WebSocketImpl ?? (globalThis.WebSocket as unknown as WsLikeCtor | undefined);
117
+ if (ctor === undefined) {
118
+ throw new Error('no WebSocket implementation available; pass WebSocketImpl');
119
+ }
120
+ this.WsCtor = ctor;
121
+ this.reconnectDelayMs = opts.reconnectDelayMs ?? 500;
122
+ this.connect();
123
+ }
124
+
125
+ /** Tear the socket down permanently. */
126
+ close(): void {
127
+ this.manualClose = true;
128
+ if (this.reconnectTimer !== undefined) {
129
+ clearTimeout(this.reconnectTimer);
130
+ this.reconnectTimer = undefined;
131
+ }
132
+ const ws = this.ws;
133
+ this.ws = undefined;
134
+ ws?.close();
135
+ }
136
+
137
+ private connect(): void {
138
+ const protocols =
139
+ this.token !== undefined && this.token.length > 0
140
+ ? [`${WS_BEARER_PROTOCOL_PREFIX}${this.token}`]
141
+ : undefined;
142
+ let ws: WsLike;
143
+ try {
144
+ ws = new this.WsCtor(this.wsUrl, protocols);
145
+ } catch {
146
+ this.scheduleReconnect();
147
+ return;
148
+ }
149
+ this.ws = ws;
150
+ ws.addEventListener('open', () => {
151
+ this.reconnectAttempt = 0;
152
+ this.send({
153
+ type: 'client_hello',
154
+ id: `kimi-inspect-global-${Date.now().toString(36)}`,
155
+ payload: { client_id: 'kimi-inspect', subscriptions: [] },
156
+ });
157
+ // Established (first connect and every reconnect alike): live facts may
158
+ // have been missed — the consumer re-seeds from REST.
159
+ this.handlers.onReconnected();
160
+ });
161
+ ws.addEventListener('message', (event: { data: unknown }) => {
162
+ this.onMessage(event.data);
163
+ });
164
+ ws.addEventListener('close', () => {
165
+ // Stale socket (a manual close already cleared `this.ws`).
166
+ if (this.ws !== ws) return;
167
+ this.ws = undefined;
168
+ if (!this.manualClose) this.scheduleReconnect();
169
+ });
170
+ ws.addEventListener('error', () => {
171
+ // The 'close' event always follows 'error'; reconnect logic lives there.
172
+ });
173
+ }
174
+
175
+ private onMessage(raw: unknown): void {
176
+ let frame: ServerFrame;
177
+ try {
178
+ frame = JSON.parse(typeof raw === 'string' ? raw : String(raw)) as ServerFrame;
179
+ } catch {
180
+ return;
181
+ }
182
+ const sessionId = frame.session_id;
183
+ if (typeof sessionId !== 'string' || sessionId === '') return;
184
+ switch (frame.type) {
185
+ case 'event.session.work_changed': {
186
+ const facts = parseWorkFacts(frame.payload);
187
+ if (facts !== undefined) this.handlers.onWorkChanged(sessionId, facts);
188
+ return;
189
+ }
190
+ case 'event.session.created': {
191
+ this.handlers.onSessionCreated(sessionId);
192
+ return;
193
+ }
194
+ case 'event.session.archived': {
195
+ const payload = frame.payload as { sessionId?: unknown } | undefined;
196
+ const archivedId = payload?.sessionId;
197
+ if (typeof archivedId === 'string' && archivedId !== '') {
198
+ this.handlers.onSessionArchived?.(archivedId);
199
+ }
200
+ return;
201
+ }
202
+ case 'event.session.deleted': {
203
+ const payload = frame.payload as { sessionId?: unknown } | undefined;
204
+ const deletedId = payload?.sessionId;
205
+ if (typeof deletedId === 'string' && deletedId !== '') {
206
+ this.handlers.onSessionDeleted?.(deletedId);
207
+ }
208
+ return;
209
+ }
210
+ case 'event.workspace.created':
211
+ case 'event.workspace.updated':
212
+ case 'event.workspace.deleted': {
213
+ this.handlers.onWorkspaceChanged?.();
214
+ return;
215
+ }
216
+ case 'session.meta.updated': {
217
+ this.handlers.onMetaUpdated(sessionId);
218
+ return;
219
+ }
220
+ case 'event.di.unit_changed': {
221
+ const payload = parseDiUnitChangedPayload(frame.payload);
222
+ if (payload !== undefined) this.handlers.onDiUnitChanged?.(payload);
223
+ return;
224
+ }
225
+ case 'ping': {
226
+ const nonce = (frame.payload as { nonce?: unknown } | undefined)?.nonce;
227
+ this.send({ type: 'pong', payload: { nonce } });
228
+ return;
229
+ }
230
+ default:
231
+ return;
232
+ }
233
+ }
234
+
235
+ private scheduleReconnect(): void {
236
+ if (this.manualClose) return;
237
+ this.reconnectAttempt += 1;
238
+ const delay = Math.min(this.reconnectDelayMs * 2 ** (this.reconnectAttempt - 1), 10_000);
239
+ this.reconnectTimer = setTimeout(() => {
240
+ this.reconnectTimer = undefined;
241
+ this.connect();
242
+ }, delay);
243
+ this.reconnectTimer.unref?.();
244
+ }
245
+
246
+ private send(frame: Record<string, unknown>): void {
247
+ const ws = this.ws;
248
+ if (ws === undefined || ws.readyState !== this.WsCtor.OPEN) return;
249
+ try {
250
+ ws.send(JSON.stringify(frame));
251
+ } catch {
252
+ // best-effort; the close handler handles teardown
253
+ }
254
+ }
255
+ }
256
+
257
+ function parseWorkFacts(payload: unknown): SessionWorkFacts | undefined {
258
+ if (typeof payload !== 'object' || payload === null) return undefined;
259
+ const p = payload as Record<string, unknown>;
260
+ if (typeof p['busy'] !== 'boolean') return undefined;
261
+ const pending = p['pending_interaction'];
262
+ const reason = p['last_turn_reason'];
263
+ return {
264
+ busy: p['busy'],
265
+ mainTurnActive: p['main_turn_active'] === true,
266
+ pendingInteraction: pending === 'approval' || pending === 'question' ? pending : 'none',
267
+ lastTurnReason:
268
+ reason === 'completed' || reason === 'cancelled' || reason === 'failed' ? reason : undefined,
269
+ };
270
+ }
271
+
272
+ const DI_UNIT_STATES: ReadonlySet<string> = new Set([
273
+ 'Pending',
274
+ 'Activating',
275
+ 'Active',
276
+ 'Unloading',
277
+ 'Failed',
278
+ ]);
279
+
280
+ function parseDiUnitChangedPayload(payload: unknown): DiUnitChangedPayload | undefined {
281
+ if (typeof payload !== 'object' || payload === null) return undefined;
282
+ const p = payload as Record<string, unknown>;
283
+ if (typeof p['scope'] !== 'string' || typeof p['token'] !== 'string') return undefined;
284
+ const state = p['state'];
285
+ if (typeof state !== 'string' || !DI_UNIT_STATES.has(state)) return undefined;
286
+ return {
287
+ scope: p['scope'],
288
+ token: p['token'],
289
+ state: state as DiUnitState,
290
+ error: typeof p['error'] === 'string' ? p['error'] : undefined,
291
+ };
292
+ }
293
+
294
+ /** Derive the `/api/v1/ws` WebSocket URL from a server base URL (or pass a full ws URL through). */
295
+ function toWsUrl(base: string): string {
296
+ const url = new URL(base);
297
+ if (url.protocol === 'http:') url.protocol = 'ws:';
298
+ else if (url.protocol === 'https:') url.protocol = 'wss:';
299
+ if (url.protocol !== 'ws:' && url.protocol !== 'wss:') {
300
+ throw new Error(`unsupported URL scheme for WS transport: ${base}`);
301
+ }
302
+ if (!url.pathname.endsWith('/api/v1/ws')) {
303
+ url.pathname = `${url.pathname.replace(/\/$/, '')}/api/v1/ws`;
304
+ }
305
+ url.search = '';
306
+ url.hash = '';
307
+ return url.toString();
308
+ }
apps/kimi-inspect/src/audit/audit.test.ts ADDED
@@ -0,0 +1,266 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Audit-layer tests: the trail recorder, the structural diff, serialization,
3
+ * and tail-preserving truncation used by the chat view's audit panel.
4
+ */
5
+
6
+ import type { StepMessage, TurnMessage } from '@moonshot-ai/kap-server/protocol';
7
+ import { describe, expect, it } from 'vitest';
8
+
9
+ import { EMPTY_CHAT_STATE, type ChatState } from '../transcript/store';
10
+ import { diffValue, type DiffNode } from './diff';
11
+ import { serializeState } from './serialize';
12
+ import { AuditTrail, AUDIT_TRAIL_MAX_ENTRIES } from './trail';
13
+ import { tailTrunc } from './truncate';
14
+
15
+ const T0 = Date.parse('2026-01-01T00:00:00.000Z');
16
+ let tick = 0;
17
+
18
+ function ts(): number {
19
+ tick += 1;
20
+ return T0 + tick * 1000;
21
+ }
22
+
23
+ function turnMsg(n: number, status: 'running' | 'completed' = 'completed'): TurnMessage {
24
+ return {
25
+ type: 'turn',
26
+ session_id: 's1',
27
+ agent_id: 'main',
28
+ timestamp: ts(),
29
+ turn_id: `t${n}`,
30
+ ordinal: n,
31
+ status,
32
+ origin: { kind: 'user' },
33
+ };
34
+ }
35
+
36
+ function stepMsg(stepId: string, status: 'running' | 'completed'): StepMessage {
37
+ return {
38
+ type: 'step',
39
+ session_id: 's1',
40
+ agent_id: 'main',
41
+ timestamp: ts(),
42
+ step_id: stepId,
43
+ turn_id: stepId.split('.')[0] ?? 't1',
44
+ ordinal: Number(stepId.split('.')[1] ?? '1'),
45
+ status,
46
+ };
47
+ }
48
+
49
+ function stateWithTimeline(items: readonly (TurnMessage | StepMessage)[]): ChatState {
50
+ return {
51
+ ...EMPTY_CHAT_STATE,
52
+ entries: items.map((message) => ({
53
+ key: message.type === 'turn' ? `turn:${message.turn_id}` : `step:${message.step_id}`,
54
+ message,
55
+ })),
56
+ };
57
+ }
58
+
59
+ // ---------------------------------------------------------------- diff
60
+
61
+ describe('diffValue', () => {
62
+ it('collapses reference-equal subtrees to unchanged without children', () => {
63
+ const shared = { a: 1, b: { c: 'x' } };
64
+ const node = diffValue({ v: shared }, { v: shared });
65
+ expect(node.status).toBe('unchanged');
66
+ expect(node.children?.get('v')?.children).toBeUndefined();
67
+ });
68
+
69
+ it('marks added, removed, and modified object keys', () => {
70
+ const node = diffValue(
71
+ { keep: 1, gone: 'x', changed: 'a' },
72
+ { keep: 1, fresh: true, changed: 'b' },
73
+ );
74
+ expect(node.status).toBe('modified');
75
+ expect(node.children?.get('keep')?.status).toBe('unchanged');
76
+ expect(node.children?.get('fresh')?.status).toBe('added');
77
+ expect(node.children?.get('gone')).toMatchObject({ status: 'removed', prev: 'x' });
78
+ expect(node.children?.get('changed')).toMatchObject({
79
+ status: 'modified',
80
+ prev: 'a',
81
+ value: 'b',
82
+ });
83
+ });
84
+
85
+ it('matches entity arrays by id instead of index', () => {
86
+ const t1 = turnMsg(1);
87
+ const t2 = turnMsg(2);
88
+ const prev = [t1, t2];
89
+ const next = [t1, { ...t2, status: 'running' as const }, turnMsg(3)];
90
+ const node = diffValue(prev, next);
91
+ expect(node.children?.get('t1')?.status).toBe('unchanged');
92
+ expect(node.children?.get('t2')?.status).toBe('modified');
93
+ expect(node.children?.get('t2')?.children?.get('status')).toMatchObject({
94
+ status: 'modified',
95
+ prev: 'completed',
96
+ value: 'running',
97
+ });
98
+ expect(node.children?.get('t3')?.status).toBe('added');
99
+ });
100
+
101
+ it('keys steps by step_id (not their shared turn_id) so siblings never collide', () => {
102
+ const done = stepMsg('t1.1', 'completed');
103
+ const node = diffValue(
104
+ [done, stepMsg('t1.2', 'completed')],
105
+ [done, stepMsg('t1.2', 'running')],
106
+ );
107
+ expect([...(node.children?.keys() ?? [])]).toEqual(['t1.1', 't1.2']);
108
+ expect(node.children?.get('t1.1')?.status).toBe('unchanged');
109
+ expect(node.children?.get('t1.2')?.status).toBe('modified');
110
+ });
111
+
112
+ it('marks removed array elements by id', () => {
113
+ const t2 = turnMsg(2);
114
+ const node = diffValue([turnMsg(1), t2], [t2]);
115
+ expect(node.children?.get('t1')).toMatchObject({ status: 'removed' });
116
+ expect(node.children?.get('t2')?.status).toBe('unchanged');
117
+ });
118
+
119
+ it('marks whole-subtree adds/removes without descending', () => {
120
+ const added = diffValue(undefined, { nested: { deep: 1 } });
121
+ expect(added.status).toBe('added');
122
+ expect(added.children).toBeUndefined();
123
+ const removed = diffValue({ nested: 1 }, undefined);
124
+ expect(removed.status).toBe('removed');
125
+ expect(removed.children).toBeUndefined();
126
+ });
127
+
128
+ it('treats type changes as leaf modifications', () => {
129
+ expect(diffValue('1', 1).status).toBe('modified');
130
+ expect(diffValue(null, {}).status).toBe('modified');
131
+ expect(diffValue([1], { 0: 1 }).status).toBe('modified');
132
+ });
133
+
134
+ it('diffs two serialized states with session.state changes visible', () => {
135
+ const base = stateWithTimeline([turnMsg(1)]);
136
+ const prev = serializeState(base);
137
+ const nextState: ChatState = {
138
+ ...base,
139
+ sessionState: {
140
+ type: 'session.state',
141
+ session_id: 's1',
142
+ timestamp: ts(),
143
+ status: 'running',
144
+ goal: { objective: 'ship it', status: 'active' },
145
+ modes: { plan: { review_path: '/tmp/plan.md' } },
146
+ },
147
+ };
148
+ const node: DiffNode = diffValue(prev, serializeState(nextState));
149
+ expect(node.children?.get('timeline')?.status).toBe('unchanged');
150
+ const sessionState = node.children?.get('sessionState');
151
+ expect(sessionState?.status).toBe('added');
152
+ expect(sessionState?.children).toBeUndefined();
153
+ });
154
+ });
155
+
156
+ // ---------------------------------------------------------------- serialize
157
+
158
+ describe('serializeState', () => {
159
+ it('turns maps into sorted plain objects and flattens the timeline', () => {
160
+ const state: ChatState = {
161
+ ...EMPTY_CHAT_STATE,
162
+ entries: stateWithTimeline([turnMsg(1)]).entries,
163
+ tasks: new Map([
164
+ [
165
+ 'b-task',
166
+ {
167
+ type: 'task',
168
+ session_id: 's1',
169
+ agent_id: 'main',
170
+ timestamp: ts(),
171
+ task_id: 'b-task',
172
+ kind: 'shell',
173
+ status: 'running',
174
+ detached: false,
175
+ output_tail: '',
176
+ },
177
+ ],
178
+ [
179
+ 'a-task',
180
+ {
181
+ type: 'task',
182
+ session_id: 's1',
183
+ agent_id: 'main',
184
+ timestamp: ts(),
185
+ task_id: 'a-task',
186
+ kind: 'tool',
187
+ status: 'completed',
188
+ detached: false,
189
+ output_tail: '',
190
+ },
191
+ ],
192
+ ]),
193
+ };
194
+ const out = serializeState(state);
195
+ expect(Object.keys(out.tasks)).toEqual(['a-task', 'b-task']);
196
+ expect(out.timeline.map((m) => (m.type === 'turn' ? m.turn_id : ''))).toEqual(['t1']);
197
+ expect(out.hasMoreOlder).toBe(false);
198
+ });
199
+ });
200
+
201
+ // ---------------------------------------------------------------- truncate
202
+
203
+ describe('tailTrunc', () => {
204
+ it('returns short strings unchanged', () => {
205
+ expect(tailTrunc('hello')).toBe('hello');
206
+ expect(tailTrunc('x'.repeat(500))).toBe('x'.repeat(500));
207
+ });
208
+
209
+ it('keeps the tail of long strings and reports the total length', () => {
210
+ const value = 'head-padding'.repeat(100) + 'THE-TAIL';
211
+ const out = tailTrunc(value, 50);
212
+ expect(out).toContain(`${value.length} chars total`);
213
+ expect(out.endsWith('THE-TAIL')).toBe(true);
214
+ expect(out).not.toContain('head-padding'.repeat(10));
215
+ });
216
+ });
217
+
218
+ // ---------------------------------------------------------------- trail
219
+
220
+ describe('AuditTrail', () => {
221
+ it('records entries with increasing indices, timestamps, and state references', () => {
222
+ const trail = new AuditTrail();
223
+ const s1 = stateWithTimeline([turnMsg(1)]);
224
+ const s2 = stateWithTimeline([turnMsg(1), turnMsg(2)]);
225
+ trail.recordRest({ pageSize: 500 }, 'replace', 1, { turn_id: 't1', step_id: 't1.1' }, s1);
226
+ trail.recordWs(turnMsg(2, 'running'), s2);
227
+ trail.recordEvent('prompt', 'hello', s2);
228
+
229
+ const entries = trail.getEntries();
230
+ expect(entries.map((entry) => entry.kind)).toEqual(['rest', 'ws', 'event']);
231
+ expect(entries.map((entry) => entry.index)).toEqual([0, 1, 2]);
232
+ expect(entries[0]!.state).toBe(s1);
233
+ expect(entries[1]!.state).toBe(s2);
234
+ expect(entries[0]).toMatchObject({ mode: 'replace', messageCount: 1 });
235
+ expect(entries[2]).toMatchObject({ event: 'prompt', detail: 'hello' });
236
+ expect(entries.every((entry) => typeof entry.at === 'string' && entry.at.length > 0)).toBe(
237
+ true,
238
+ );
239
+ expect(entries.every((entry) => entry.summary.length > 0)).toBe(true);
240
+ });
241
+
242
+ it('notifies subscribers on each record', () => {
243
+ const trail = new AuditTrail();
244
+ let notified = 0;
245
+ const unsubscribe = trail.subscribe(() => {
246
+ notified += 1;
247
+ });
248
+ trail.recordEvent('cancel', undefined, EMPTY_CHAT_STATE);
249
+ trail.recordEvent('ack', undefined, EMPTY_CHAT_STATE);
250
+ expect(notified).toBe(2);
251
+ unsubscribe();
252
+ trail.recordEvent('reconnect', undefined, EMPTY_CHAT_STATE);
253
+ expect(notified).toBe(2);
254
+ });
255
+
256
+ it('drops the oldest entries beyond the cap while indices keep increasing', () => {
257
+ const trail = new AuditTrail();
258
+ for (let i = 0; i < AUDIT_TRAIL_MAX_ENTRIES + 10; i += 1) {
259
+ trail.recordEvent('prompt', `p${i}`, EMPTY_CHAT_STATE);
260
+ }
261
+ const entries = trail.getEntries();
262
+ expect(entries).toHaveLength(AUDIT_TRAIL_MAX_ENTRIES);
263
+ expect(entries[0]!.index).toBe(10);
264
+ expect(entries.at(-1)!.index).toBe(AUDIT_TRAIL_MAX_ENTRIES + 9);
265
+ });
266
+ });
apps/kimi-inspect/src/audit/diff.ts ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Structural diff over serialized `ChatState` values (see `serialize.ts`).
3
+ *
4
+ * The audit panel diffs two adjacent, immutable states. Because the store
5
+ * is copy-on-write, untouched subtrees share references — the reference
6
+ * equality fast path below collapses them to `unchanged` without walking.
7
+ *
8
+ * Arrays of protocol entities are matched by their id field (turn_id,
9
+ * step_id, message_id, tool_call_id, …) rather than by index, so an upsert
10
+ * in the middle of the timeline does not turn into a cascade of spurious
11
+ * modifications.
12
+ */
13
+
14
+ export type DiffStatus = 'unchanged' | 'added' | 'removed' | 'modified';
15
+
16
+ export interface DiffNode {
17
+ readonly status: DiffStatus;
18
+ /** Current value (`undefined` when removed). */
19
+ readonly value: unknown;
20
+ /** Previous value (`undefined` when added). */
21
+ readonly prev: unknown;
22
+ /**
23
+ * Object/array children — key is the object key, the entity id, or
24
+ * `#<index>` for plain arrays. Absent on leaves and on whole-subtree
25
+ * added/removed nodes (the renderer colors the subtree as one block).
26
+ */
27
+ readonly children?: ReadonlyMap<string, DiffNode>;
28
+ }
29
+
30
+ /**
31
+ * Id fields checked in priority order — MOST SPECIFIC FIRST. An interaction
32
+ * carries both `interaction_id` and `tool_call_id`, a tool call can carry
33
+ * `task_id` / `todo_id` alongside its `tool_call_id`, and every timeline
34
+ * entity carries `turn_id`; matching the wrong one mislabels the node and,
35
+ * worse, collides siblings in the children map (two tool calls of one task
36
+ * both keyed by that task id).
37
+ */
38
+ const ID_FIELDS = [
39
+ 'message_id',
40
+ 'interaction_id',
41
+ 'tool_call_id',
42
+ 'task_id',
43
+ 'todo_id',
44
+ 'system_id',
45
+ 'step_id',
46
+ 'turn_id',
47
+ ] as const;
48
+
49
+ function elementId(element: unknown): string | undefined {
50
+ if (typeof element !== 'object' || element === null) return undefined;
51
+ for (const field of ID_FIELDS) {
52
+ const value = (element as Record<string, unknown>)[field];
53
+ if (typeof value === 'string') return value;
54
+ }
55
+ return undefined;
56
+ }
57
+
58
+ /** Public for the audit UI: same id-priority keying used to match array elements. */
59
+ export { elementId };
60
+
61
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
62
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
63
+ }
64
+
65
+ function containerStatus(children: ReadonlyMap<string, DiffNode>): DiffStatus {
66
+ for (const child of children.values()) {
67
+ if (child.status !== 'unchanged') return 'modified';
68
+ }
69
+ return 'unchanged';
70
+ }
71
+
72
+ function diffObjects(prev: Record<string, unknown>, next: Record<string, unknown>): DiffNode {
73
+ const children = new Map<string, DiffNode>();
74
+ for (const key of Object.keys(next)) {
75
+ children.set(key, diffValue(prev[key], next[key]));
76
+ }
77
+ for (const key of Object.keys(prev)) {
78
+ if (!(key in next)) {
79
+ children.set(key, { status: 'removed', value: undefined, prev: prev[key] });
80
+ }
81
+ }
82
+ return { status: containerStatus(children), value: next, prev, children };
83
+ }
84
+
85
+ function diffArrays(prev: readonly unknown[], next: readonly unknown[]): DiffNode {
86
+ const children = new Map<string, DiffNode>();
87
+ const keyed =
88
+ prev.every((el) => elementId(el) !== undefined) &&
89
+ next.every((el) => elementId(el) !== undefined);
90
+
91
+ if (keyed) {
92
+ const prevById = new Map<string, unknown>();
93
+ for (const el of prev) prevById.set(elementId(el) as string, el);
94
+ const nextIds = new Set<string>();
95
+ for (const el of next) {
96
+ const id = elementId(el) as string;
97
+ nextIds.add(id);
98
+ children.set(id, diffValue(prevById.get(id), el));
99
+ }
100
+ for (const el of prev) {
101
+ const id = elementId(el) as string;
102
+ if (!nextIds.has(id)) children.set(id, { status: 'removed', value: undefined, prev: el });
103
+ }
104
+ } else {
105
+ for (let i = 0; i < next.length; i += 1) {
106
+ children.set(`#${i}`, diffValue(prev[i], next[i]));
107
+ }
108
+ for (let i = next.length; i < prev.length; i += 1) {
109
+ children.set(`#${i}`, { status: 'removed', value: undefined, prev: prev[i] });
110
+ }
111
+ }
112
+ return { status: containerStatus(children), value: next, prev, children };
113
+ }
114
+
115
+ export function diffValue(prev: unknown, next: unknown): DiffNode {
116
+ if (prev === next) return { status: 'unchanged', value: next, prev };
117
+ if (prev === undefined) return { status: 'added', value: next, prev: undefined };
118
+ if (next === undefined) return { status: 'removed', value: undefined, prev };
119
+ if (isPlainObject(prev) && isPlainObject(next)) return diffObjects(prev, next);
120
+ if (Array.isArray(prev) && Array.isArray(next)) return diffArrays(prev, next);
121
+ return { status: 'modified', value: next, prev };
122
+ }
apps/kimi-inspect/src/audit/serialize.ts ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Serialize a `ChatState` into a plain, JSON-shaped object for the audit
3
+ * panel's state tree and structural diff. Maps become key-sorted plain
4
+ * objects (stable display order); everything else is passed through by
5
+ * reference (state is immutable, so sharing is safe and keeps the
6
+ * reference-equality fast path in `diffValue` useful).
7
+ */
8
+
9
+ import type {
10
+ InteractionMessage,
11
+ SessionStateMessage,
12
+ TaskMessage,
13
+ TodoMessage,
14
+ } from '@moonshot-ai/kap-server/protocol';
15
+
16
+ import type { ChatState, TimelineMessage } from '../transcript/store';
17
+
18
+ /** Plain-object view of a `ChatState` (Maps unwrapped). */
19
+ export interface SerializedChatState {
20
+ readonly timeline: readonly TimelineMessage[];
21
+ readonly interactions: Record<string, InteractionMessage>;
22
+ readonly tasks: Record<string, TaskMessage>;
23
+ readonly todos: Record<string, TodoMessage>;
24
+ readonly sessionState: SessionStateMessage | undefined;
25
+ readonly hasMoreOlder: boolean;
26
+ }
27
+
28
+ function mapToSortedObject<V>(map: ReadonlyMap<string, V>): Record<string, V> {
29
+ const out: Record<string, V> = {};
30
+ for (const key of [...map.keys()].toSorted()) out[key] = map.get(key) as V;
31
+ return out;
32
+ }
33
+
34
+ export function serializeState(state: ChatState): SerializedChatState {
35
+ return {
36
+ timeline: state.entries.map((entry) => entry.message),
37
+ interactions: mapToSortedObject(state.interactions),
38
+ tasks: mapToSortedObject(state.tasks),
39
+ todos: mapToSortedObject(state.todos),
40
+ sessionState: state.sessionState,
41
+ hasMoreOlder: state.hasMoreOlder,
42
+ };
43
+ }
apps/kimi-inspect/src/audit/trail.ts ADDED
@@ -0,0 +1,198 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Audit trail for the chat view's message-protocol channel.
3
+ *
4
+ * A pure observer: the chat pipeline (REST history loads, WS messages, user
5
+ * actions) calls the `record*` methods AFTER applying each step to the real
6
+ * `ChatStore`, passing the resulting immutable `ChatState` reference.
7
+ * Replaying the trail is therefore free — every entry already holds the
8
+ * exact state the store had at that point, ready for the timeline slider
9
+ * and the structural diff.
10
+ */
11
+
12
+ import type { ServerMessage } from '@moonshot-ai/kap-server/protocol';
13
+
14
+ import type { ChatState } from '../transcript/store';
15
+
16
+ export const AUDIT_TRAIL_MAX_ENTRIES = 5000;
17
+
18
+ interface AuditEntryBase {
19
+ /** Position in the trail (stable even when old entries are dropped). */
20
+ readonly index: number;
21
+ /** Local record time (ISO). */
22
+ readonly at: string;
23
+ /** Store state right after this entry was applied (immutable reference). */
24
+ readonly state: ChatState;
25
+ /** One-line summary for the timeline list. */
26
+ readonly summary: string;
27
+ }
28
+
29
+ export interface RestAuditEntry extends AuditEntryBase {
30
+ readonly kind: 'rest';
31
+ readonly request: {
32
+ readonly beforeTurn?: string | undefined;
33
+ readonly afterStep?: string | undefined;
34
+ readonly pageSize: number;
35
+ };
36
+ /** replace = newest page (initial/refresh); prepend = older page; tail = after_step catch-up. */
37
+ readonly mode: 'replace' | 'prepend' | 'tail';
38
+ readonly messageCount: number;
39
+ readonly inFlight?: { turn_id: string; step_id: string } | undefined;
40
+ }
41
+
42
+ export interface WsAuditEntry extends AuditEntryBase {
43
+ readonly kind: 'ws';
44
+ /** The raw server message as applied to the store (entity, delta, or state). */
45
+ readonly message: ServerMessage;
46
+ }
47
+
48
+ export interface EventAuditEntry extends AuditEntryBase {
49
+ readonly kind: 'event';
50
+ readonly event:
51
+ | 'ack'
52
+ | 'ack-error'
53
+ | 'reconnect'
54
+ | 'catchup-refresh'
55
+ | 'protocol-error'
56
+ | 'invalid-frame'
57
+ | 'prompt'
58
+ | 'cancel'
59
+ | 'older-error';
60
+ readonly detail?: string | undefined;
61
+ }
62
+
63
+ export type AuditEntry = RestAuditEntry | WsAuditEntry | EventAuditEntry;
64
+
65
+ type DistributiveOmit<T, K extends PropertyKey> = T extends unknown ? Omit<T, K> : never;
66
+
67
+ /** Entry payload accepted by `push` (index/at are filled in there). */
68
+ type AuditEntryInput = DistributiveOmit<AuditEntry, 'index' | 'at'>;
69
+
70
+ export class AuditTrail {
71
+ private entryList: AuditEntry[] = [];
72
+ private nextIndex = 0;
73
+ private readonly listeners = new Set<() => void>();
74
+
75
+ /** `useSyncExternalStore`-compatible subscribe. */
76
+ subscribe = (listener: () => void): (() => void) => {
77
+ this.listeners.add(listener);
78
+ return () => {
79
+ this.listeners.delete(listener);
80
+ };
81
+ };
82
+
83
+ getEntries(): readonly AuditEntry[] {
84
+ return this.entryList;
85
+ }
86
+
87
+ recordRest(
88
+ request: RestAuditEntry['request'],
89
+ mode: RestAuditEntry['mode'],
90
+ messageCount: number,
91
+ inFlight: RestAuditEntry['inFlight'],
92
+ state: ChatState,
93
+ ): void {
94
+ const cursor =
95
+ request.beforeTurn !== undefined
96
+ ? `?before_turn=${request.beforeTurn}`
97
+ : request.afterStep !== undefined
98
+ ? `?after_step=${request.afterStep}`
99
+ : '';
100
+ const flight = inFlight !== undefined ? ` (in_flight ${inFlight.step_id})` : '';
101
+ this.push({
102
+ kind: 'rest',
103
+ request,
104
+ mode,
105
+ messageCount,
106
+ inFlight,
107
+ state,
108
+ summary: `GET history${cursor} → ${messageCount} messages (${mode})${flight}`,
109
+ });
110
+ }
111
+
112
+ recordWs(message: ServerMessage, state: ChatState): void {
113
+ this.push({
114
+ kind: 'ws',
115
+ message,
116
+ state,
117
+ summary: summarizeMessage(message),
118
+ });
119
+ }
120
+
121
+ recordEvent(
122
+ event: EventAuditEntry['event'],
123
+ detail: string | undefined,
124
+ state: ChatState,
125
+ ): void {
126
+ const label =
127
+ event === 'ack'
128
+ ? 'subscribe ack → after_step catch-up'
129
+ : event === 'ack-error'
130
+ ? 'subscribe ack error'
131
+ : event === 'reconnect'
132
+ ? 'socket dropped → reconnecting'
133
+ : event === 'catchup-refresh'
134
+ ? 'catch-up anchor gone → full refresh'
135
+ : event === 'protocol-error'
136
+ ? 'protocol error frame'
137
+ : event === 'invalid-frame'
138
+ ? 'invalid frame (server bug)'
139
+ : event === 'prompt'
140
+ ? 'prompt sent'
141
+ : event === 'cancel'
142
+ ? 'cancel sent'
143
+ : 'older-page load failed';
144
+ this.push({
145
+ kind: 'event',
146
+ event,
147
+ detail,
148
+ state,
149
+ summary: detail !== undefined && detail !== '' ? `${label}: ${detail}` : label,
150
+ });
151
+ }
152
+
153
+ private push(entry: AuditEntryInput): void {
154
+ const full = { ...entry, index: this.nextIndex, at: new Date().toISOString() } as AuditEntry;
155
+ this.nextIndex += 1;
156
+ const kept =
157
+ this.entryList.length >= AUDIT_TRAIL_MAX_ENTRIES
158
+ ? this.entryList.slice(this.entryList.length - AUDIT_TRAIL_MAX_ENTRIES + 1)
159
+ : this.entryList;
160
+ this.entryList = [...kept, full];
161
+ for (const listener of this.listeners) listener();
162
+ }
163
+ }
164
+
165
+ function summarizeMessage(message: ServerMessage): string {
166
+ switch (message.type) {
167
+ case 'turn':
168
+ return `turn ${message.turn_id} (${message.status})`;
169
+ case 'step':
170
+ return `step ${message.step_id} (${message.status})`;
171
+ case 'user':
172
+ return `user ${message.message_id}`;
173
+ case 'assistant':
174
+ case 'thinking':
175
+ return `${message.type} ${message.message_id} (${message.status})`;
176
+ case 'assistant.delta':
177
+ case 'thinking.delta':
178
+ return `${message.type} ${message.message_id} +${message.text.length}ch`;
179
+ case 'tool_call':
180
+ return `tool_call ${message.name} ${message.tool_call_id} (${message.status})`;
181
+ case 'tool_call.delta':
182
+ return `tool_call.delta ${message.tool_call_id} +${message.input_text.length}ch`;
183
+ case 'tool.progress':
184
+ return `tool.progress ${message.tool_call_id} (${message.progress.kind})`;
185
+ case 'system':
186
+ return `system(${message.subtype}) ${message.system_id}`;
187
+ case 'interaction':
188
+ return `interaction ${message.interaction_id} (${message.kind}/${message.status})`;
189
+ case 'task':
190
+ return `task ${message.task_id} (${message.kind}/${message.status})`;
191
+ case 'todo':
192
+ return `todo ${message.todo_id} (${message.items.length} items)`;
193
+ case 'session.state':
194
+ return `session.state (${message.status})`;
195
+ default:
196
+ return message.type;
197
+ }
198
+ }
apps/kimi-inspect/src/audit/truncate.ts ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Tail-preserving string truncation for the audit panel: long values are
3
+ * rendered with their total length plus the LAST `keep` characters (the
4
+ * tail is where streaming text, tool output, and prompts carry the newest
5
+ * information). Rendering-only — the underlying state is never truncated,
6
+ * and no field is ever dropped.
7
+ */
8
+
9
+ export const TRUNCATE_KEEP = 500;
10
+
11
+ export function tailTrunc(value: string, keep: number = TRUNCATE_KEEP): string {
12
+ if (value.length <= keep) return value;
13
+ return `… [${value.length} chars total, showing last ${keep}]\n${value.slice(-keep)}`;
14
+ }
apps/kimi-inspect/src/channel/channel.test.ts ADDED
@@ -0,0 +1,199 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Channel layer unit tests — `ProxyChannel` URL/envelope semantics,
3
+ * `makeProxy` routing, the HTTP-only `listen` failure, and the debug-surface
4
+ * probe (`/api/v1/debug` is the only RPC surface; there is no v2 fallback).
5
+ */
6
+
7
+ import { afterEach, describe, expect, it, vi } from 'vitest';
8
+
9
+ import type { Event, IChannel } from './channel';
10
+ import { probeDebugSurface } from './channels';
11
+ import { createInspectClient } from './client';
12
+ import { RPCError } from './errors';
13
+ import {
14
+ fetchAgentRuntimeBinding,
15
+ fetchSessionWorkspaceAssociation,
16
+ fetchWorkspaceSnapshot,
17
+ } from '../snapshots/api';
18
+ import { makeProxy } from './proxy';
19
+ import { ProxyChannel } from './proxyChannel';
20
+
21
+ const ok = (data: unknown) => ({ code: 0, msg: 'success', data, request_id: 'r1' });
22
+
23
+ function fakeFetch(envelope: unknown) {
24
+ const calls: { url: string; init?: RequestInit }[] = [];
25
+ const fetchImpl = (async (url: string | URL, init?: RequestInit) => {
26
+ calls.push({ url: String(url), init });
27
+ return { json: async () => envelope };
28
+ }) as unknown as typeof fetch;
29
+ return { calls, fetchImpl };
30
+ }
31
+
32
+ afterEach(() => {
33
+ vi.unstubAllGlobals();
34
+ });
35
+
36
+ describe('ProxyChannel.call', () => {
37
+ it('POSTs the command to the service base URL; no body and no header without args/token', async () => {
38
+ const { calls, fetchImpl } = fakeFetch(ok({ id: 's1' }));
39
+ const channel = new ProxyChannel({
40
+ baseUrl: 'http://h:1/api/v1/debug/session/s%201/agent/main/agentLoopService',
41
+ fetch: fetchImpl,
42
+ });
43
+ const result = await channel.call('getModel', []);
44
+ expect(result).toEqual({ id: 's1' });
45
+ expect(calls).toHaveLength(1);
46
+ expect(calls[0]!.url).toBe(
47
+ 'http://h:1/api/v1/debug/session/s%201/agent/main/agentLoopService/getModel',
48
+ );
49
+ expect(calls[0]!.init?.method).toBe('POST');
50
+ expect(calls[0]!.init?.body).toBeUndefined();
51
+ });
52
+
53
+ it('sends the complete argument array as the JSON body, plus the bearer token', async () => {
54
+ const { calls, fetchImpl } = fakeFetch(ok(null));
55
+ const channel = new ProxyChannel({
56
+ baseUrl: 'http://h:2/api/v1/debug/configService',
57
+ token: 'tok',
58
+ fetch: fetchImpl,
59
+ });
60
+ await channel.call('set', ['workspace', { theme: 'dark' }]);
61
+ expect(calls[0]!.init?.body).toBe(JSON.stringify(['workspace', { theme: 'dark' }]));
62
+ expect(calls[0]!.init?.headers).toEqual({
63
+ 'content-type': 'application/json',
64
+ authorization: 'Bearer tok',
65
+ });
66
+ });
67
+
68
+ it('unwraps the envelope and throws RPCError on a non-zero code', async () => {
69
+ const { fetchImpl } = fakeFetch({
70
+ code: 40401,
71
+ msg: 'session not found',
72
+ data: null,
73
+ request_id: 'r2',
74
+ details: { id: 's9' },
75
+ });
76
+ const channel = new ProxyChannel({
77
+ baseUrl: 'http://h:3/api/v1/debug/sessionIndex',
78
+ fetch: fetchImpl,
79
+ });
80
+ const err: unknown = await channel.call('get', ['s9']).catch((error: unknown) => error);
81
+ expect(err).toBeInstanceOf(RPCError);
82
+ expect((err as RPCError).code).toBe(40401);
83
+ expect((err as RPCError).message).toBe('session not found');
84
+ expect((err as RPCError).details).toEqual({ id: 's9' });
85
+ });
86
+ });
87
+
88
+ describe('makeProxy', () => {
89
+ interface DemoService {
90
+ read(id: string, n: number): Promise<string>;
91
+ onDidChangeMetadata: Event<{ title: string }>;
92
+ }
93
+
94
+ it('routes methods to call and onXxx members to listen', async () => {
95
+ const seen = { calls: [] as [string, unknown[]][], listens: [] as string[] };
96
+ const channel: IChannel = {
97
+ call: async <T>(command: string, args?: unknown[]): Promise<T> => {
98
+ seen.calls.push([command, args ?? []]);
99
+ return 'ret' as T;
100
+ },
101
+ listen: <T>(event: string): Event<T> => {
102
+ seen.listens.push(event);
103
+ return () => ({ dispose: () => {} });
104
+ },
105
+ };
106
+ const svc = makeProxy<DemoService>(channel);
107
+ await expect(svc.read('a', 1)).resolves.toBe('ret');
108
+ expect(seen.calls).toEqual([['read', ['a', 1]]]);
109
+ const d = svc.onDidChangeMetadata(() => {});
110
+ d.dispose();
111
+ expect(seen.listens).toEqual(['onDidChangeMetadata']);
112
+ });
113
+ });
114
+
115
+ describe('ProxyChannel.listen', () => {
116
+ it('throws: the debug surface is HTTP-only, there is no event transport', () => {
117
+ const channel = new ProxyChannel({
118
+ baseUrl: 'http://h:4/api/v1/debug/configService',
119
+ fetch: fakeFetch(ok(null)).fetchImpl,
120
+ });
121
+ expect(() => channel.listen('onDidChangeConfiguration')).toThrow(/events are not supported/);
122
+ });
123
+ });
124
+
125
+ describe('business snapshots', () => {
126
+ it('uses explicit workspace, session association, and agent binding routes', async () => {
127
+ const calls: string[] = [];
128
+ vi.stubGlobal('fetch', async (url: string | URL) => {
129
+ const value = String(url);
130
+ calls.push(value);
131
+ if (value.endsWith('/workspace/w%201/snapshot')) {
132
+ return { json: async () => ok({ metadata: { id: 'w 1' } }) };
133
+ }
134
+ if (value.endsWith('/session/s%201/association')) {
135
+ return { json: async () => ok({ sessionId: 's 1', workspaceId: 'w 1', cwd: '/work' }) };
136
+ }
137
+ return {
138
+ json: async () => ok({
139
+ binding: { workspaceId: 'w 1', runtimeId: 'remote' },
140
+ available: true,
141
+ runtime: { runtimeId: 'remote', generation: 'g2', status: 'ready', capabilities: ['process'] },
142
+ }),
143
+ };
144
+ });
145
+ const client = createInspectClient({ url: 'http://h:9', token: 'tok' });
146
+
147
+ await expect(fetchWorkspaceSnapshot(client, 'w 1')).resolves.toMatchObject({ metadata: { id: 'w 1' } });
148
+ await expect(fetchSessionWorkspaceAssociation(client, 's 1')).resolves.toMatchObject({ workspaceId: 'w 1' });
149
+ await expect(fetchAgentRuntimeBinding(client, 's 1', 'main')).resolves.toMatchObject({
150
+ binding: { runtimeId: 'remote' },
151
+ runtime: { generation: 'g2' },
152
+ });
153
+ expect(calls).toEqual([
154
+ 'http://h:9/api/v1/debug/workspace/w%201/snapshot',
155
+ 'http://h:9/api/v1/debug/session/s%201/association',
156
+ 'http://h:9/api/v1/debug/session/s%201/agent/main/runtime-binding',
157
+ ]);
158
+ });
159
+ });
160
+
161
+ describe('probeDebugSurface', () => {
162
+ function stubProbeFetch(impl: (url: string, init?: RequestInit) => unknown) {
163
+ const calls: { url: string; init?: RequestInit }[] = [];
164
+ vi.stubGlobal('fetch', async (url: string | URL, init?: RequestInit) => {
165
+ calls.push({ url: String(url), init });
166
+ return impl(String(url), init);
167
+ });
168
+ return calls;
169
+ }
170
+
171
+ it('resolves when /api/v1/debug/channels answers a zero-code envelope (with bearer header)', async () => {
172
+ const calls = stubProbeFetch(() => ({ ok: true, json: async () => ({ code: 0 }) }));
173
+ await expect(
174
+ probeDebugSurface({ baseUrl: 'http://h:5/', token: 'tok' }),
175
+ ).resolves.toBeUndefined();
176
+ expect(calls[0]!.url).toBe('http://h:5/api/v1/debug/channels');
177
+ expect(calls[0]!.init?.headers).toEqual({ authorization: 'Bearer tok' });
178
+ });
179
+
180
+ it('throws a --debug-endpoints hint when the surface is not mounted (HTTP 404)', async () => {
181
+ stubProbeFetch(() => ({ ok: false, status: 404 }));
182
+ await expect(probeDebugSurface({ baseUrl: 'http://h:6' })).rejects.toThrow(/--debug-endpoints/);
183
+ });
184
+
185
+ it('throws an unreachable-server error when fetch itself fails', async () => {
186
+ vi.stubGlobal('fetch', async () => {
187
+ throw new Error('ECONNREFUSED');
188
+ });
189
+ await expect(probeDebugSurface({ baseUrl: 'http://h:7' })).rejects.toThrow(/cannot reach/);
190
+ });
191
+
192
+ it('throws a token hint when the envelope carries a non-zero code', async () => {
193
+ stubProbeFetch(() => ({
194
+ ok: true,
195
+ json: async () => ({ code: 40101, msg: 'unauthorized' }),
196
+ }));
197
+ await expect(probeDebugSurface({ baseUrl: 'http://h:8' })).rejects.toThrow(/bearer token/);
198
+ });
199
+ });
apps/kimi-inspect/src/channel/channel.ts ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Transport-agnostic channel contract for the debug-RPC client — the
3
+ * old-klient / VS Code `ProxyChannel` model: the channel is bound to one
4
+ * Service (the URL carries the scope + the Service's decorator id) and
5
+ * `command` is the method name, invoked by reflection on the server.
6
+ *
7
+ * `listen` is kept for contract completeness (Service `onXxx` emitters map to
8
+ * it), but the `/api/v1/debug` surface is HTTP-only — the v2 event socket
9
+ * (`/api/v2/ws`) that used to serve it was removed — so the HTTP channel's
10
+ * `listen` throws and panels fetch on demand instead.
11
+ */
12
+
13
+ import type { ServiceIdentifier } from '@moonshot-ai/agent-core-v2/_base/di/instantiation';
14
+
15
+ export interface IDisposable {
16
+ dispose(): void;
17
+ }
18
+
19
+ export interface Event<T> {
20
+ (listener: (event: T) => unknown, thisArg?: unknown, disposables?: IDisposable[]): IDisposable;
21
+ }
22
+
23
+ /** The client-facing channel contract. Calls always carry the complete argument array. */
24
+ export interface IChannel {
25
+ call<T>(command: string, args?: unknown[]): Promise<T>;
26
+ listen<T>(event: string, arg?: unknown): Event<T>;
27
+ }
28
+
29
+ /** A wire Service reference: a DI decorator (stringifies to the wire channel
30
+ * name) or the raw channel name as a string. */
31
+ export type ServiceRef<T> = ServiceIdentifier<T> | string;
32
+
33
+ /**
34
+ * Remote view of a Service contract: every method becomes an async wire call;
35
+ * `onXxx` event members (`Event<T>` — callables returning `IDisposable`) stay
36
+ * subscribable events; plain non-function members become zero-arg property
37
+ * reads (the dispatcher returns non-function members as-is).
38
+ */
39
+ export type ServiceProxy<T> = {
40
+ [K in keyof T]: T[K] extends (...args: infer A) => infer R
41
+ ? R extends IDisposable
42
+ ? T[K]
43
+ : (...args: A) => Promise<Awaited<R>>
44
+ : () => Promise<Awaited<T[K]>>;
45
+ };
apps/kimi-inspect/src/channel/channels.ts ADDED
@@ -0,0 +1,120 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Protocol loading — the debug surface's `GET /api/v1/debug/channels`
3
+ * endpoint is the server's self-description of every wire-callable Service
4
+ * (name, scope, domain, methods + properties), whitelist-free. Paired with
5
+ * `serviceByName`, each descriptor materializes 1:1 into a typed proxy of
6
+ * the channel layer: same channel name, same scope route, methods invoked by
7
+ * reflection.
8
+ *
9
+ * `/api/v1/debug` is the ONLY RPC surface this app talks to (mounted by
10
+ * kap-server with `--debug-endpoints` on a loopback bind); the v2 surface
11
+ * (`/api/v2` + `/api/v2/ws`) was removed server-side, so there is no
12
+ * fallback — `probeDebugSurface` fails the connection with a clear error.
13
+ */
14
+
15
+ import { createDecorator } from '@moonshot-ai/agent-core-v2/_base/di/instantiation';
16
+
17
+ import type { ServiceProxy } from './channel';
18
+ import { DEBUG_RPC_BASE, type InspectClient } from './client';
19
+ import { RPCError } from './errors';
20
+
21
+ /** Wire scope kinds reported by the channels endpoint (`app` ≡ the core route). */
22
+ export type ChannelScope = 'app' | 'session' | 'agent';
23
+
24
+ /** Mirror of `ChannelDescriptor` in kap-server (`GET /api/v1/debug/channels`). */
25
+ export interface ChannelDescriptor {
26
+ readonly name: string;
27
+ readonly scope: ChannelScope;
28
+ readonly domain: string;
29
+ readonly methods: readonly {
30
+ readonly name: string;
31
+ readonly kind: 'method' | 'property';
32
+ readonly arity: number;
33
+ readonly params: string;
34
+ }[];
35
+ }
36
+
37
+ /** Fetch the dynamic channel list (unwrapped from the project envelope). */
38
+ export async function fetchChannelDescriptors(
39
+ client: InspectClient,
40
+ ): Promise<readonly ChannelDescriptor[]> {
41
+ const headers: Record<string, string> = {};
42
+ if (client.token !== undefined && client.token !== '') {
43
+ headers['authorization'] = `Bearer ${client.token}`;
44
+ }
45
+ const res = await fetch(`${client.baseUrl}${DEBUG_RPC_BASE}/channels`, { headers });
46
+ const envelope = (await res.json()) as {
47
+ code: number;
48
+ msg: string;
49
+ data: readonly ChannelDescriptor[];
50
+ };
51
+ if (envelope.code !== 0) throw new RPCError(envelope.code, envelope.msg);
52
+ return envelope.data;
53
+ }
54
+
55
+ /**
56
+ * Verify the server mounts the debug RPC surface before the client is built.
57
+ * Resolves silently when `GET /api/v1/debug/channels` answers with a
58
+ * zero-code envelope; otherwise throws an `Error` whose message tells the
59
+ * user exactly what is wrong (unreachable server, surface not mounted →
60
+ * start kap-server with `--debug-endpoints`, or a rejected probe → check the
61
+ * token).
62
+ */
63
+ export async function probeDebugSurface(options: {
64
+ readonly baseUrl: string;
65
+ readonly token?: string;
66
+ }): Promise<void> {
67
+ const headers: Record<string, string> = {};
68
+ if (options.token !== undefined && options.token !== '') {
69
+ headers['authorization'] = `Bearer ${options.token}`;
70
+ }
71
+ const url = `${options.baseUrl.replace(/\/$/, '')}${DEBUG_RPC_BASE}/channels`;
72
+ let res: Response;
73
+ try {
74
+ res = await fetch(url, { headers });
75
+ } catch (error) {
76
+ const reason = error instanceof Error ? error.message : String(error);
77
+ throw new Error(`cannot reach ${options.baseUrl} — is kap-server running? (${reason})`);
78
+ }
79
+ if (!res.ok) {
80
+ throw new Error(
81
+ `GET ${DEBUG_RPC_BASE}/channels answered HTTP ${res.status} — this server does not ` +
82
+ 'mount the debug RPC surface. Start kap-server with --debug-endpoints on a loopback bind.',
83
+ );
84
+ }
85
+ const envelope = (await res.json()) as { code?: number; msg?: string };
86
+ if (envelope.code !== 0) {
87
+ throw new Error(
88
+ `the debug surface rejected the probe (code ${envelope.code ?? '?'}: ${
89
+ envelope.msg ?? 'no message'
90
+ }) — check the bearer token.`,
91
+ );
92
+ }
93
+ }
94
+
95
+ export interface ServiceTarget {
96
+ readonly scope: ChannelScope;
97
+ readonly sessionId?: string;
98
+ readonly agentId?: string;
99
+ }
100
+
101
+ /**
102
+ * Resolve a Service proxy by wire channel name. The DI decorator registry keys
103
+ * identifiers by name, so re-creating the decorator resolves to the same token
104
+ * the server channel registry created — the name is the wire channel, which is
105
+ * all the proxy uses. Returns `undefined` when the target scope needs a
106
+ * workspace/session/agent id that isn't available.
107
+ */
108
+ export function serviceByName<T extends object>(
109
+ client: InspectClient,
110
+ name: string,
111
+ target: ServiceTarget,
112
+ ): ServiceProxy<T> | undefined {
113
+ const id = createDecorator<T>(name);
114
+ if (target.scope === 'app') return client.core(id);
115
+ if (target.sessionId === undefined) return undefined;
116
+ const base = client.session(target.sessionId);
117
+ if (target.scope === 'session') return base.service(id);
118
+ if (target.agentId === undefined) return undefined;
119
+ return base.agent(target.agentId).service(id);
120
+ }
apps/kimi-inspect/src/channel/client.ts ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Inspect client — the app's `/api/v1/debug` entry point, in the old-klient
3
+ * VS Code `ProxyChannel` model: a multi-level scope entry (`core` /
4
+ * `workspace` / `session` / `agent`) whose every Service handle is a
5
+ * `makeProxy`-materialized typed proxy over a service-bound HTTP channel.
6
+ *
7
+ * const client = createInspectClient({ url: 'http://127.0.0.1:58627' });
8
+ * await client.core(ISessionIndex).listRecent({});
9
+ * await client.workspace('wd_1').service(ISessionLifecycleService).resume('s1');
10
+ * await client.session('s1').service(ISessionMetadata).read();
11
+ * await client.session('s1').agent('main').service(IAgentLoopService).cancel(undefined);
12
+ *
13
+ * The `agent-core-v2` service token is the whole key: its type parameter `T`
14
+ * types the returned proxy, and its decorator id (`String(id)`) is the channel
15
+ * name in the URL. Calls ride HTTP (`ProxyChannel`). There is no event
16
+ * transport: the v2 socket (`/api/v2/ws`) that used to carry Service `onXxx`
17
+ * emitters and scope event streams was removed server-side, so the UI reads
18
+ * Service state on demand. (The transcript's own `/api/v1/ws` delta channel
19
+ * lives in `src/transcript/` and is unrelated to this client.)
20
+ */
21
+
22
+ import type { ServiceProxy, ServiceRef } from './channel';
23
+ import { makeProxy } from './proxy';
24
+ import { ProxyChannel } from './proxyChannel';
25
+
26
+ /** The dev server's whitelist-free debug surface (`--debug-endpoints` + loopback). */
27
+ export const DEBUG_RPC_BASE = '/api/v1/debug' as const;
28
+
29
+ export interface InspectAgentHandle {
30
+ service<T extends object>(id: ServiceRef<T>): ServiceProxy<T>;
31
+ }
32
+
33
+ export interface InspectSessionHandle extends InspectAgentHandle {
34
+ agent(agentId: string): InspectAgentHandle;
35
+ }
36
+
37
+ export interface InspectClient {
38
+ /** Absolute server base URL, e.g. `http://127.0.0.1:58627`. */
39
+ readonly baseUrl: string;
40
+ /** Bearer token in use, when any. */
41
+ readonly token?: string;
42
+ core<T extends object>(id: ServiceRef<T>): ServiceProxy<T>;
43
+ session(sessionId: string): InspectSessionHandle;
44
+ }
45
+
46
+ export interface InspectClientOptions {
47
+ /** Base URL of the server, e.g. `http://127.0.0.1:58627`. */
48
+ readonly url: string;
49
+ /** Optional bearer token. */
50
+ readonly token?: string;
51
+ }
52
+
53
+ export function createInspectClient(options: InspectClientOptions): InspectClient {
54
+ const url = options.url.replace(/\/$/, '');
55
+
56
+ /** Materialize a typed proxy for one Service on one scope binding. */
57
+ function proxy<T extends object>(scopePath: string, id: ServiceRef<T>): ServiceProxy<T> {
58
+ const service = String(id);
59
+ return makeProxy<T>(
60
+ new ProxyChannel({
61
+ baseUrl: `${url}${DEBUG_RPC_BASE}${scopePath}/${service}`,
62
+ token: options.token,
63
+ }),
64
+ );
65
+ }
66
+
67
+ return {
68
+ baseUrl: url,
69
+ token: options.token,
70
+ core: (id) => proxy('', id),
71
+ session: (sessionId) => {
72
+ const scopePath = `/session/${encodeURIComponent(sessionId)}`;
73
+ return {
74
+ service: (id) => proxy(scopePath, id),
75
+ agent: (agentId) => ({
76
+ service: (subId) => proxy(`${scopePath}/agent/${encodeURIComponent(agentId)}`, subId),
77
+ }),
78
+ };
79
+ },
80
+ };
81
+ }
apps/kimi-inspect/src/channel/errors.ts ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Client-side RPC error surfaced when the debug-RPC envelope carries a
3
+ * non-zero `code`. Mirrors the server envelope (`{ code, msg, data,
4
+ * request_id }`) — the numeric `code` is the stable branch key across the
5
+ * wire, not `instanceof`.
6
+ */
7
+ export class RPCError extends Error {
8
+ constructor(
9
+ readonly code: number,
10
+ message: string,
11
+ readonly details?: unknown,
12
+ ) {
13
+ super(message);
14
+ this.name = 'RPCError';
15
+ }
16
+ }
apps/kimi-inspect/src/channel/index.ts ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ export * from './channel';
2
+ export * from './channels';
3
+ export * from './client';
4
+ export * from './errors';
5
+ export * from './proxy';
6
+ export * from './proxyChannel';
7
+ export * from './wsLike';
apps/kimi-inspect/src/channel/proxy.ts ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Typed proxy turning an `IChannel` (bound to one Service) into a value
3
+ * satisfying that Service's interface `T` — VS Code's `ProxyChannel.toService`.
4
+ *
5
+ * Members named `onUpperCase` become channel events; every other property access
6
+ * becomes a function forwarding its complete argument array to `channel.call`
7
+ * (the dispatcher also answers property reads this way). The shared interface
8
+ * `T` is the whole contract, with no per-method allowlist or renaming.
9
+ */
10
+
11
+ import type { IChannel, ServiceProxy } from './channel';
12
+
13
+ export function makeProxy<T extends object>(channel: IChannel): ServiceProxy<T> {
14
+ return new Proxy({} as ServiceProxy<T>, {
15
+ get(_target, prop) {
16
+ if (typeof prop !== 'string') return undefined;
17
+ if (/^on[A-Z]/.test(prop)) return channel.listen(prop);
18
+ return (...args: unknown[]) => channel.call(prop, args);
19
+ },
20
+ });
21
+ }
apps/kimi-inspect/src/channel/proxyChannel.ts ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * `ProxyChannel` — an `IChannel` bound to one Service, routing `call`s to
3
+ * kap-server's `/api/v1/debug` HTTP surface. Every call `POST`s the method
4
+ * name to the Service base URL with the complete argument array as the JSON
5
+ * body, then unwraps the project envelope: a non-zero `code` throws
6
+ * `RPCError`, otherwise `data` is returned. Non-function members answer as
7
+ * property reads through the same route (the dispatcher returns them as-is).
8
+ *
9
+ * `listen` cannot be served by HTTP: the v2 event socket (`/api/v2/ws`) that
10
+ * used to back Service emitter events was removed, so `listen` throws and
11
+ * the UI fetches Service state on demand instead.
12
+ */
13
+
14
+ import type { Event, IChannel } from './channel';
15
+ import { RPCError } from './errors';
16
+
17
+ interface Envelope<T> {
18
+ readonly code: number;
19
+ readonly msg: string;
20
+ readonly data: T;
21
+ readonly request_id: string;
22
+ readonly details?: unknown;
23
+ }
24
+
25
+ export interface ProxyChannelOptions {
26
+ /** Service base URL, e.g. `http://127.0.0.1:58627/api/v1/debug[/session/:sid[/agent/:aid]]/:service`. */
27
+ readonly baseUrl: string;
28
+ /** Optional bearer token. */
29
+ readonly token?: string;
30
+ /** `fetch` implementation; defaults to the global `fetch`. */
31
+ readonly fetch?: typeof fetch;
32
+ }
33
+
34
+ export class ProxyChannel implements IChannel {
35
+ private readonly baseUrl: string;
36
+ private readonly token?: string;
37
+ private readonly fetchImpl: typeof fetch;
38
+
39
+ constructor(opts: ProxyChannelOptions) {
40
+ this.baseUrl = opts.baseUrl.replace(/\/$/, '');
41
+ this.token = opts.token;
42
+ // Bind the global fetch: browsers throw "Illegal invocation" when the
43
+ // native function is invoked with a non-Window receiver.
44
+ this.fetchImpl = opts.fetch ?? fetch.bind(globalThis);
45
+ }
46
+
47
+ async call<T>(command: string, args: unknown[] = []): Promise<T> {
48
+ const headers: Record<string, string> = {};
49
+ let body: string | undefined;
50
+ if (args.length > 0) {
51
+ headers['content-type'] = 'application/json';
52
+ body = JSON.stringify(args);
53
+ }
54
+ if (this.token !== undefined) {
55
+ headers['authorization'] = `Bearer ${this.token}`;
56
+ }
57
+ const res = await this.fetchImpl(`${this.baseUrl}/${command}`, {
58
+ method: 'POST',
59
+ headers,
60
+ body,
61
+ });
62
+ const envelope = (await res.json()) as Envelope<T>;
63
+ if (envelope.code !== 0) {
64
+ throw new RPCError(envelope.code, envelope.msg, envelope.details);
65
+ }
66
+ return envelope.data;
67
+ }
68
+
69
+ listen<T>(_event: string): Event<T> {
70
+ throw new Error(
71
+ 'events are not supported on this channel (HTTP-only; the /api/v2/ws event socket was removed)',
72
+ );
73
+ }
74
+ }
apps/kimi-inspect/src/channel/wsLike.ts ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Minimal DOM-compatible WebSocket surface shared by the app's socket
3
+ * clients (today only the transcript `/api/v1/ws` client). Coding against
4
+ * this structural type keeps the clients testable with an injected fake;
5
+ * the default is the global `WebSocket` (browsers, Node ≥ 21).
6
+ */
7
+ export interface WsLike {
8
+ readonly readyState: number;
9
+ send(data: string): void;
10
+ close(code?: number, reason?: string): void;
11
+ addEventListener(
12
+ type: 'open' | 'message' | 'close' | 'error',
13
+ listener: (event: never) => void,
14
+ ): void;
15
+ }
16
+
17
+ export interface WsLikeCtor {
18
+ new (url: string, protocols?: string | string[]): WsLike;
19
+ readonly OPEN: number;
20
+ }
apps/kimi-inspect/src/components/AppServicesView.tsx ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * App Services view — the app-scope (server-level) Service reflection as a
3
+ * standalone rail view. Postman-style three-pane layout
4
+ * (`ScopePanelsScrollspy`): the Service list on the left, every Service's
5
+ * methods expanded in one continuously scrolling column in the middle
6
+ * (scroll position and left-side highlight kept in sync), and a
7
+ * request/response call history on the right. The proxies resolve on the
8
+ * `core` route, so this view works before any session is selected.
9
+ */
10
+
11
+ import { useCallback } from 'react';
12
+
13
+ import { serviceByName } from '../channel';
14
+ import { useConnection } from '../connection';
15
+ import type { AnyService } from '../panels';
16
+ import { ScopePanelsScrollspy } from './ServicePanels';
17
+
18
+ export function AppServicesView() {
19
+ const { klient } = useConnection();
20
+ const proxyFor = useCallback(
21
+ (name: string): AnyService | null =>
22
+ serviceByName<AnyService>(klient, name, { scope: 'app' }) ?? null,
23
+ [klient],
24
+ );
25
+
26
+ return <ScopePanelsScrollspy scope="app" title="App Services" proxyFor={proxyFor} />;
27
+ }
apps/kimi-inspect/src/components/BashParserView.tsx ADDED
@@ -0,0 +1,279 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Bash Parser view — a playground for the App-scope `IBashParserService`
3
+ * (the `bashParser` domain, a thin adapter over `@moonshot-ai/tree-sitter-bash`).
4
+ *
5
+ * left: the bash source textarea plus the parse budget (timeoutMs /
6
+ * maxNodes, empty = package default); the `examples…` dropdown
7
+ * fills the textarea with curated snippets from the parser's own
8
+ * differential fixtures;
9
+ * right: the parse result — status badges (hasError / aborted / node
10
+ * count) and the syntax tree, one row per node with its type,
11
+ * UTF-16 range and (for leaves) the source text. Anonymous tokens
12
+ * are dimmed; rows expand/collapse.
13
+ *
14
+ * Parsing is debounced off the textarea and rides the same `/api/v1/debug`
15
+ * channel as every other panel (`klient.core(IBashParserService).parse`) —
16
+ * the budgeted parse never throws, `{ ok: false }` means budget exhaustion.
17
+ */
18
+
19
+ import { useEffect, useState } from 'react';
20
+
21
+ import {
22
+ IBashParserService,
23
+ type BashParseResult,
24
+ type BashSyntaxNode,
25
+ } from '@moonshot-ai/agent-core-v2/app/bashParser/bashParser';
26
+
27
+ import { useConnection } from '../connection';
28
+ import { Badge, errorMessage } from '../ui';
29
+
30
+ const DEFAULT_SOURCE = `if [ -f config.sh ]; then
31
+ source config.sh && echo "loaded" | tee -a setup.log
32
+ else
33
+ echo "missing" >&2; exit 1
34
+ fi
35
+ `;
36
+
37
+ const PARSE_DEBOUNCE_MS = 300;
38
+
39
+ /**
40
+ * Quick-fill examples, adapted from the parser's own differential fixtures
41
+ * (`packages/tree-sitter-bash/test/fixtures/differential/*.txt`) — each one
42
+ * exercises a distinct area of the grammar. The last three probe the
43
+ * non-happy paths: deep nesting (a left-associative arithmetic chain, the
44
+ * case that once overflowed the DTO conversion) and the error-recovery
45
+ * paths that set `hasError`.
46
+ */
47
+ const EXAMPLES: readonly { readonly name: string; readonly source: string }[] = [
48
+ {
49
+ name: 'deep arithmetic (1000 operands)',
50
+ // A thousand left-nested binary_expression levels. Deeper chains parse
51
+ // fine in-process, but past ~2500 levels the JSON RPC transport itself
52
+ // cannot serialize the tree (V8 call-stack limit in JSON.stringify).
53
+ source: `echo $((${'1+'.repeat(1000)}1))`,
54
+ },
55
+ {
56
+ name: 'pipeline & redirects',
57
+ source: `git log --oneline | head -20 | tee /tmp/log.txt
58
+ find . -name '*.ts' -print0 2>/dev/null | xargs -0 grep -l TODO
59
+ cmd <<< "$input" >out.txt 2>&1
60
+ `,
61
+ },
62
+ {
63
+ name: 'case statement',
64
+ source: `case $x in
65
+ a) echo A ;;
66
+ b|c) echo BC ;&
67
+ foo*|bar) echo match ;;
68
+ [a-z]) echo lower ;;
69
+ *) echo other ;;
70
+ esac
71
+ `,
72
+ },
73
+ {
74
+ name: 'heredoc',
75
+ source: `foo() { cat <<EOF
76
+ body $x
77
+ EOF
78
+ }
79
+
80
+ cat <<-'RAW'
81
+ indented $notexpanded
82
+ RAW
83
+ `,
84
+ },
85
+ {
86
+ name: 'expansions & arithmetic',
87
+ source: `echo "\${var:-default} \${#arr[@]} \${path##*/}"
88
+ echo $((x << 2 | 1)) $[y + 1]
89
+ result=$(command -v jq) && echo "$result"
90
+ `,
91
+ },
92
+ {
93
+ name: 'process substitution',
94
+ source: `diff <(sort a.txt) <(sort b.txt)
95
+ while read -r line; do echo "$line"; done < <(git status --short)
96
+ `,
97
+ },
98
+ {
99
+ name: 'loops & conditions',
100
+ source: `for f in src/*.ts; do
101
+ [ -f "$f" ] || continue
102
+ grep -q FIXME "$f" && echo "$f has FIXME"
103
+ done
104
+
105
+ while IFS= read -r line; do printf '%s\\n' "$line"; done < list.txt
106
+ until [ "$n" -le 0 ]; do n=$((n - 1)); done
107
+ `,
108
+ },
109
+ {
110
+ name: 'error: unclosed substitution',
111
+ source: 'echo $(date | wc -l\n',
112
+ },
113
+ {
114
+ name: 'error: unterminated if',
115
+ source: `if cat <<EOF; then x; fi
116
+ body
117
+ EOF
118
+ `,
119
+ },
120
+ ];
121
+
122
+ function countNodes(node: BashSyntaxNode): number {
123
+ return 1 + node.children.reduce((sum, child) => sum + countNodes(child), 0);
124
+ }
125
+
126
+ export function BashParserView() {
127
+ const { klient } = useConnection();
128
+ const [source, setSource] = useState(DEFAULT_SOURCE);
129
+ const [timeoutMs, setTimeoutMs] = useState('');
130
+ const [maxNodes, setMaxNodes] = useState('');
131
+ const [result, setResult] = useState<BashParseResult | null>(null);
132
+ const [error, setError] = useState<string | null>(null);
133
+
134
+ useEffect(() => {
135
+ const handle = setTimeout(() => {
136
+ klient
137
+ .core(IBashParserService)
138
+ .parse(source, {
139
+ timeoutMs: timeoutMs === '' ? undefined : Number(timeoutMs),
140
+ maxNodes: maxNodes === '' ? undefined : Number(maxNodes),
141
+ })
142
+ .then(setResult, (e: unknown) => {
143
+ setResult(null);
144
+ setError(errorMessage(e));
145
+ });
146
+ }, PARSE_DEBOUNCE_MS);
147
+ return () => {
148
+ clearTimeout(handle);
149
+ };
150
+ }, [klient, source, timeoutMs, maxNodes]);
151
+
152
+ const nodeCount = result !== null && result.ok ? countNodes(result.root) : null;
153
+
154
+ return (
155
+ <div className="flex min-h-0 flex-1">
156
+ <div className="flex w-[44%] shrink-0 flex-col border-r border-neutral-800">
157
+ <div className="flex items-center gap-3 border-b border-neutral-800 px-3 py-2">
158
+ <span className="text-[11px] font-semibold text-neutral-300">bash source</span>
159
+ <select
160
+ className="rounded border border-neutral-700 bg-neutral-950 px-1.5 py-0.5 text-[11px] text-neutral-300 outline-none focus:border-sky-600"
161
+ value=""
162
+ onChange={(e) => {
163
+ const example = EXAMPLES.find((x) => x.name === e.target.value);
164
+ if (example !== undefined) setSource(example.source);
165
+ }}
166
+ >
167
+ <option value="" disabled>
168
+ examples…
169
+ </option>
170
+ {EXAMPLES.map((x) => (
171
+ <option key={x.name} value={x.name}>
172
+ {x.name}
173
+ </option>
174
+ ))}
175
+ </select>
176
+ <div className="flex-1" />
177
+ <label className="flex items-center gap-1 text-[10px] text-neutral-500">
178
+ timeoutMs
179
+ <input
180
+ className="w-16 rounded border border-neutral-700 bg-neutral-950 px-1.5 py-0.5 font-mono text-[11px] text-neutral-200 outline-none focus:border-sky-600"
181
+ placeholder="50"
182
+ value={timeoutMs}
183
+ onChange={(e) => setTimeoutMs(e.target.value)}
184
+ />
185
+ </label>
186
+ <label className="flex items-center gap-1 text-[10px] text-neutral-500">
187
+ maxNodes
188
+ <input
189
+ className="w-20 rounded border border-neutral-700 bg-neutral-950 px-1.5 py-0.5 font-mono text-[11px] text-neutral-200 outline-none focus:border-sky-600"
190
+ placeholder="50000"
191
+ value={maxNodes}
192
+ onChange={(e) => setMaxNodes(e.target.value)}
193
+ />
194
+ </label>
195
+ </div>
196
+ <textarea
197
+ className="min-h-0 flex-1 resize-none bg-neutral-950 p-3 font-mono text-[12px] leading-relaxed text-neutral-200 outline-none"
198
+ spellCheck={false}
199
+ value={source}
200
+ onChange={(e) => setSource(e.target.value)}
201
+ />
202
+ </div>
203
+ <div className="flex min-h-0 min-w-0 flex-1 flex-col">
204
+ <div className="flex items-center gap-2 border-b border-neutral-800 px-3 py-2">
205
+ <span className="text-[11px] font-semibold text-neutral-300">syntax tree</span>
206
+ {error !== null ? <Badge tone="red">rpc error</Badge> : null}
207
+ {result !== null && !result.ok ? <Badge tone="red">aborted</Badge> : null}
208
+ {result !== null && result.ok && result.hasError ? (
209
+ <Badge tone="amber">hasError</Badge>
210
+ ) : null}
211
+ {nodeCount !== null ? <Badge tone="neutral">{nodeCount} nodes</Badge> : null}
212
+ </div>
213
+ <div className="min-h-0 flex-1 overflow-auto p-2">
214
+ {error !== null ? (
215
+ <div className="rounded bg-red-950/50 px-2 py-1 text-[11px] text-red-400">{error}</div>
216
+ ) : result === null ? (
217
+ <div className="text-[11px] text-neutral-600 italic">parsing…</div>
218
+ ) : !result.ok ? (
219
+ <div className="text-[11px] text-neutral-500">
220
+ Parse budget exhausted (<span className="font-mono">reason: {result.reason}</span>) —
221
+ the tree cannot be analyzed; raise the budget or shrink the input.
222
+ </div>
223
+ ) : (
224
+ <SyntaxTreeNode node={result.root} depth={0} defaultDepth={2} />
225
+ )}
226
+ </div>
227
+ </div>
228
+ </div>
229
+ );
230
+ }
231
+
232
+ function SyntaxTreeNode({
233
+ node,
234
+ depth,
235
+ defaultDepth,
236
+ }: {
237
+ readonly node: BashSyntaxNode;
238
+ readonly depth: number;
239
+ readonly defaultDepth: number;
240
+ }) {
241
+ const [open, setOpen] = useState(depth < defaultDepth);
242
+ const expandable = node.children.length > 0;
243
+ const range = `[${String(node.startIndex)}, ${String(node.endIndex)})`;
244
+ const text = node.text.length > 60 ? `${node.text.slice(0, 60)}…` : node.text;
245
+
246
+ return (
247
+ <div>
248
+ <div
249
+ className="flex cursor-pointer items-baseline gap-2 truncate rounded px-1 font-mono text-[11px] leading-[1.7] hover:bg-neutral-800/70"
250
+ style={{ paddingLeft: `${depth * 14 + 4}px` }}
251
+ onClick={() => {
252
+ if (expandable) setOpen((v) => !v);
253
+ }}
254
+ title={node.text}
255
+ >
256
+ <span className="select-none text-neutral-600">
257
+ {expandable ? (open ? '▾' : '▸') : '·'}
258
+ </span>
259
+ <span className={node.isNamed ? 'text-sky-300' : 'text-neutral-500'}>{node.type}</span>
260
+ <span className="text-neutral-600">{range}</span>
261
+ {!expandable ? (
262
+ <span className="truncate text-emerald-300/70">{JSON.stringify(text)}</span>
263
+ ) : null}
264
+ </div>
265
+ {open
266
+ ? node.children.map((child, index) => (
267
+ <SyntaxTreeNode
268
+ // Children are source-ordered and sibling ranges never overlap,
269
+ // so range + index is a stable key.
270
+ key={`${String(child.startIndex)}:${String(child.endIndex)}:${String(index)}`}
271
+ node={child}
272
+ depth={depth + 1}
273
+ defaultDepth={defaultDepth}
274
+ />
275
+ ))
276
+ : null}
277
+ </div>
278
+ );
279
+ }
apps/kimi-inspect/src/components/ChatSearchBar.tsx ADDED
@@ -0,0 +1,147 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * In-chat search bar — searches the messages of the CURRENT session only
3
+ * (`container: { session_id }` on `POST /api/v1/search`; the whole session,
4
+ * not just the active agent) and renders the first page as a dropdown under
5
+ * the input. Clicking a hit hands it to the app shell (`onOpenHit`), which
6
+ * switches the agent when the hit belongs to another one and jumps the
7
+ * transcript to the hit's turn / step. Esc or an outside click closes the
8
+ * panel.
9
+ */
10
+
11
+ import { useEffect, useRef, useState } from 'react';
12
+
13
+ import { useConnection } from '../connection';
14
+ import { fetchSearchPage, type SearchHit } from '../search/api';
15
+ import { Badge, ErrorLine, relTime } from '../ui';
16
+
17
+ const PAGE_SIZE = 20;
18
+
19
+ export function ChatSearchBar({
20
+ sessionId,
21
+ onOpenHit,
22
+ }: {
23
+ sessionId: string;
24
+ onOpenHit?: ((hit: SearchHit) => void) | undefined;
25
+ }) {
26
+ const { baseUrl, config } = useConnection();
27
+ const [input, setInput] = useState('');
28
+ const [open, setOpen] = useState(false);
29
+ const [hits, setHits] = useState<readonly SearchHit[] | null>(null);
30
+ const [searching, setSearching] = useState(false);
31
+ const [error, setError] = useState<unknown>(null);
32
+ const rootRef = useRef<HTMLDivElement>(null);
33
+
34
+ // A session switch drops the previous session's query and results.
35
+ useEffect(() => {
36
+ setInput('');
37
+ setHits(null);
38
+ setError(null);
39
+ setOpen(false);
40
+ }, [sessionId]);
41
+
42
+ // Clicking outside the bar closes the result panel.
43
+ useEffect(() => {
44
+ if (!open) return;
45
+ const onDown = (event: MouseEvent) => {
46
+ if (rootRef.current !== null && !rootRef.current.contains(event.target as Node)) {
47
+ setOpen(false);
48
+ }
49
+ };
50
+ document.addEventListener('mousedown', onDown);
51
+ return () => {
52
+ document.removeEventListener('mousedown', onDown);
53
+ };
54
+ }, [open]);
55
+
56
+ const search = async () => {
57
+ const query = input.trim();
58
+ if (query === '' || searching) return;
59
+ setSearching(true);
60
+ setError(null);
61
+ setOpen(true);
62
+ try {
63
+ const token = config.token.trim();
64
+ const page = await fetchSearchPage({
65
+ baseUrl,
66
+ token: token === '' ? undefined : token,
67
+ query,
68
+ // Substring semantics (Ctrl+F-like): terms mode is whole-token matching,
69
+ // which surprises in an in-session search box.
70
+ mode: 'literal',
71
+ container: { sessionId },
72
+ pageSize: PAGE_SIZE,
73
+ });
74
+ setHits(page.items);
75
+ } catch (error) {
76
+ setHits(null);
77
+ setError(error);
78
+ } finally {
79
+ setSearching(false);
80
+ }
81
+ };
82
+
83
+ return (
84
+ <div ref={rootRef} className="relative border-b border-neutral-800 px-4 py-2">
85
+ <input
86
+ className="w-full rounded border border-neutral-700 bg-neutral-950 px-3 py-1.5 text-[13px] text-neutral-100 outline-none focus:border-sky-600"
87
+ placeholder="Search this session… (Enter to search, Esc to close)"
88
+ value={input}
89
+ onChange={(e) => setInput(e.target.value)}
90
+ onKeyDown={(e) => {
91
+ if (e.key === 'Enter') {
92
+ e.preventDefault();
93
+ void search();
94
+ } else if (e.key === 'Escape') {
95
+ setOpen(false);
96
+ }
97
+ }}
98
+ onFocus={() => {
99
+ if (hits !== null) setOpen(true);
100
+ }}
101
+ />
102
+ {open ? (
103
+ <div className="absolute inset-x-4 top-full z-10 mt-1 max-h-80 overflow-y-auto rounded-lg border border-neutral-800 bg-neutral-900 p-2 shadow-lg shadow-black/40">
104
+ {searching ? (
105
+ <div className="px-1 py-1 text-[12px] text-neutral-600 italic">Searching…</div>
106
+ ) : error !== null ? (
107
+ <ErrorLine error={error} />
108
+ ) : hits === null ? null : hits.length === 0 ? (
109
+ <div className="px-1 py-1 text-[12px] text-neutral-600 italic">
110
+ No hits in this session.
111
+ </div>
112
+ ) : (
113
+ <div className="flex flex-col gap-1">
114
+ {hits.map((hit, i) => (
115
+ <button
116
+ key={`${hit.agentId}/${hit.time}/${i}`}
117
+ type="button"
118
+ className="w-full rounded border border-transparent px-2 py-1.5 text-left transition-colors hover:border-sky-800 hover:bg-neutral-800/60"
119
+ onClick={() => {
120
+ setOpen(false);
121
+ onOpenHit?.(hit);
122
+ }}
123
+ title="Open in chat"
124
+ >
125
+ <div className="mb-1 flex flex-wrap items-center gap-2">
126
+ <Badge
127
+ tone={
128
+ hit.role === 'user' ? 'sky' : hit.role === 'assistant' ? 'green' : 'violet'
129
+ }
130
+ >
131
+ {hit.role}
132
+ </Badge>
133
+ {hit.agentId !== '' ? <Badge tone="neutral">agent: {hit.agentId}</Badge> : null}
134
+ <span className="ml-auto text-[10px] text-neutral-600">{relTime(hit.time)}</span>
135
+ </div>
136
+ <div className="whitespace-pre-wrap text-[12px] text-neutral-300">
137
+ {hit.snippet}
138
+ </div>
139
+ </button>
140
+ ))}
141
+ </div>
142
+ )}
143
+ </div>
144
+ ) : null}
145
+ </div>
146
+ );
147
+ }
apps/kimi-inspect/src/components/ChatView.tsx ADDED
@@ -0,0 +1,1231 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Main view — the conversation of the active session + agent, rendered from
3
+ * the message protocol (`/api/v3/ws` + `GET /api/v1/sessions/{id}/history`):
4
+ *
5
+ * - Persisted state comes from the REST history endpoint only: the initial
6
+ * load reads the newest page, a full refresh re-reads it and re-covers
7
+ * the previously loaded window, and "load earlier" pages further with a
8
+ * `before_turn` cursor.
9
+ * - The WS channel carries the recovery payload and all live traffic; both
10
+ * are applied to the store through the same idempotent replace-by-id
11
+ * path (delta family appended by id, entity content authoritative), so
12
+ * there is no reset/buffer/cursor machinery.
13
+ * - Every subscribe ack (initial and reconnect) triggers an `after_step`
14
+ * catch-up from the newest terminal step; an empty catch-up whose
15
+ * anchor vanished (undo/clear while away) falls back to a full refresh.
16
+ *
17
+ * Rendering groups the flat timeline by turn (system markers stay
18
+ * standalone) and is typed entirely by the protocol schemas
19
+ * (`@moonshot-ai/kap-server/protocol`). Cancels go through the
20
+ * `agentLoopService` channel over the debug RPC
21
+ * surface (`/api/v1/debug`); interaction answers (approve/reject,
22
+ * answer/dismiss) go through the public REST endpoints
23
+ * (`src/interactions/api.ts`); the running indicator derives from
24
+ * `session.state`.
25
+ */
26
+
27
+ import { IAgentLoopService } from '@moonshot-ai/agent-core-v2/agent/loop/loop';
28
+ import type {
29
+ AssistantMessage,
30
+ ContentPart,
31
+ InteractionMessage,
32
+ InteractionQuestionItem,
33
+ SessionStateMessage,
34
+ StepMessage,
35
+ SystemMessage,
36
+ TaskMessage,
37
+ ThinkingMessage,
38
+ TodoMessage,
39
+ ToolCallMessage,
40
+ TurnMessage,
41
+ UserMessage,
42
+ } from '@moonshot-ai/kap-server/protocol';
43
+ import {
44
+ createContext,
45
+ useCallback,
46
+ useContext,
47
+ useEffect,
48
+ useLayoutEffect,
49
+ useMemo,
50
+ useRef,
51
+ useState,
52
+ useSyncExternalStore,
53
+ } from 'react';
54
+
55
+ import { AuditTrail } from '../audit/trail';
56
+ import { useConnection } from '../connection';
57
+ import {
58
+ answerQuestion,
59
+ decideApproval,
60
+ dismissQuestion,
61
+ type QuestionAnswerWire,
62
+ } from '../interactions/api';
63
+ import type { SearchHit } from '../search/api';
64
+ import { ChatChannel } from '../transcript/channel';
65
+ import {
66
+ EMPTY_CHAT_STATE,
67
+ hasTurnId,
68
+ type ChatState,
69
+ type TimelineEntry,
70
+ } from '../transcript/store';
71
+ import { ActionButton, Badge, ErrorLine, JsonView, relTime } from '../ui';
72
+ import { ChatSearchBar } from './ChatSearchBar';
73
+
74
+ const noopSubscribe = () => () => {};
75
+
76
+ /** Active session id for deeply nested interaction views (approve/answer buttons). */
77
+ const SessionContext = createContext<string>('');
78
+
79
+ /**
80
+ * A navigation request into the chat timeline (e.g. from a search hit). The
81
+ * channel pages backwards until the turn enters the loaded window, then
82
+ * scrolls it into view and flashes it briefly.
83
+ */
84
+ export interface ChatJump {
85
+ /** Turn to locate (`t<N>`); omitted = switch session/agent only, no scroll. */
86
+ readonly turnId?: string | undefined;
87
+ /** Step within the turn (`t<N>.<M>`); falls back to the turn card. */
88
+ readonly stepId?: string | undefined;
89
+ /** Changes on every request so re-clicking the same hit re-triggers. */
90
+ readonly nonce: number;
91
+ }
92
+
93
+ interface ChatChannelState {
94
+ /** Null until the effect has created the channel (pre-ready / no session). */
95
+ readonly channel: ChatChannel | null;
96
+ readonly state: ChatState;
97
+ /** Records every step that built the store (audit panel data source). */
98
+ readonly trail: AuditTrail | null;
99
+ /** True once the initial REST page load succeeded. */
100
+ readonly loaded: boolean;
101
+ /** Set when the initial/refresh load failed. */
102
+ readonly loadError: unknown;
103
+ }
104
+
105
+ /**
106
+ * Owns the channel (store + REST + WS) for one (sessionId, agentId) pair.
107
+ */
108
+ function useChatChannel(
109
+ sessionId: string | null,
110
+ agentId: string,
111
+ ready: boolean,
112
+ captureAnchor: () => void,
113
+ ): ChatChannelState {
114
+ const { baseUrl, config } = useConnection();
115
+ const token = config.token.trim();
116
+ const [channel, setChannel] = useState<ChatChannel | null>(null);
117
+ const [loaded, setLoaded] = useState(false);
118
+ const [loadError, setLoadError] = useState<unknown>(null);
119
+
120
+ useEffect(() => {
121
+ if (!ready || sessionId === null) return;
122
+ const authToken = token === '' ? undefined : token;
123
+ const next = new ChatChannel({
124
+ baseUrl,
125
+ token: authToken,
126
+ sessionId,
127
+ agentId,
128
+ onWillReplace: captureAnchor,
129
+ onLoaded: () => {
130
+ setLoaded(true);
131
+ setLoadError(null);
132
+ },
133
+ onLoadError: (error) => {
134
+ setLoadError(error);
135
+ },
136
+ });
137
+ setChannel(next);
138
+ setLoaded(false);
139
+ setLoadError(null);
140
+ next.start();
141
+ return () => {
142
+ next.close();
143
+ setChannel(null);
144
+ };
145
+ }, [sessionId, agentId, ready, baseUrl, token, captureAnchor]);
146
+
147
+ const state = useSyncExternalStore(
148
+ channel?.store.subscribe ?? noopSubscribe,
149
+ () => channel?.store.getState() ?? EMPTY_CHAT_STATE,
150
+ );
151
+ return {
152
+ channel,
153
+ state,
154
+ trail: channel?.trail ?? null,
155
+ loaded,
156
+ loadError,
157
+ };
158
+ }
159
+
160
+ export function ChatView({
161
+ sessionId,
162
+ agentId,
163
+ ready,
164
+ onTrailChange,
165
+ jump,
166
+ onJumpHandled,
167
+ onOpenSearchHit,
168
+ }: {
169
+ sessionId: string | null;
170
+ agentId: string;
171
+ ready: boolean;
172
+ /** Hands the audit trail of the current channel up to the app shell (the audit panel lives in the right dock, not inside this view). */
173
+ onTrailChange?: (trail: AuditTrail | null) => void;
174
+ /** Pending navigation into the timeline (search result click). */
175
+ jump?: ChatJump | null | undefined;
176
+ /** Called once the jump has been processed (or found un-actionable). */
177
+ onJumpHandled?: (() => void) | undefined;
178
+ /** Hands an in-chat search hit up to the app shell (agent switch + jump). */
179
+ onOpenSearchHit?: ((hit: SearchHit) => void) | undefined;
180
+ }) {
181
+ const { klient } = useConnection();
182
+ const [sendError, setSendError] = useState<unknown>(null);
183
+ const [loadingOlder, setLoadingOlder] = useState(false);
184
+ const [olderError, setOlderError] = useState<unknown>(null);
185
+ const scrollRef = useRef<HTMLDivElement>(null);
186
+ /** Distance from the scroll bottom captured before a prepend (restore anchor). */
187
+ const anchorRef = useRef<number | null>(null);
188
+ /** Whether the viewport was pinned to the bottom before the last update. */
189
+ const stickBottomRef = useRef(true);
190
+ /** The jump target being flashed (cleared on a timer). */
191
+ const [flash, setFlash] = useState<{ turnId: string; stepId?: string | undefined } | null>(null);
192
+
193
+ const captureAnchor = useCallback(() => {
194
+ const el = scrollRef.current;
195
+ if (el !== null) anchorRef.current = el.scrollHeight - el.scrollTop;
196
+ }, []);
197
+
198
+ const { channel, state, trail, loaded, loadError } = useChatChannel(
199
+ sessionId,
200
+ agentId,
201
+ ready,
202
+ captureAnchor,
203
+ );
204
+ const entries = state.entries;
205
+
206
+ // The audit panel is rendered by the app shell's right dock; report the
207
+ // trail (null while no channel exists) so it can subscribe to it there.
208
+ useEffect(() => {
209
+ onTrailChange?.(trail);
210
+ }, [onTrailChange, trail]);
211
+
212
+ // Jump navigation (search result click): once the channel has loaded, page
213
+ // backwards until the target turn enters the window, then scroll to the
214
+ // step (or the turn card) and flash it briefly. A turn that never appears
215
+ // (cut by an undo) degrades to no scroll.
216
+ useEffect(() => {
217
+ if (jump === null || jump === undefined || !loaded || channel === null || sessionId === null) {
218
+ return;
219
+ }
220
+ if (jump.turnId === undefined) {
221
+ onJumpHandled?.();
222
+ return;
223
+ }
224
+ let cancelled = false;
225
+ const isCancelled = (): boolean => cancelled;
226
+ const turnId = jump.turnId;
227
+ const stepId = jump.stepId;
228
+ void (async () => {
229
+ stickBottomRef.current = false;
230
+ const store = channel.store;
231
+ try {
232
+ while (
233
+ !hasTurnId(store.getState().entries, turnId) &&
234
+ store.getState().hasMoreOlder &&
235
+ !isCancelled()
236
+ ) {
237
+ const before = store.getState().entries.length;
238
+ await channel.loadOlder();
239
+ if (store.getState().entries.length === before) break;
240
+ }
241
+ } catch {
242
+ // A failed older-page load leaves the window as-is; degrade to no scroll.
243
+ }
244
+ if (cancelled) return;
245
+ if (!hasTurnId(store.getState().entries, turnId)) {
246
+ onJumpHandled?.();
247
+ return;
248
+ }
249
+ setFlash({ turnId, stepId });
250
+ // The prepend renders asynchronously; wait two frames before scrolling.
251
+ requestAnimationFrame(() => {
252
+ requestAnimationFrame(() => {
253
+ if (cancelled) return;
254
+ const root = scrollRef.current;
255
+ const stepEl =
256
+ stepId !== undefined
257
+ ? root?.querySelector(`[data-step-id="${CSS.escape(stepId)}"]`)
258
+ : undefined;
259
+ const target = stepEl ?? root?.querySelector(`[data-turn-id="${CSS.escape(turnId)}"]`);
260
+ target?.scrollIntoView({ block: 'start' });
261
+ });
262
+ });
263
+ onJumpHandled?.();
264
+ })();
265
+ return () => {
266
+ cancelled = true;
267
+ };
268
+ }, [jump, loaded, channel, sessionId, onJumpHandled]);
269
+
270
+ // The flash highlight clears itself after a short moment.
271
+ useEffect(() => {
272
+ if (flash === null) return;
273
+ const timer = setTimeout(() => setFlash(null), 2400);
274
+ return () => clearTimeout(timer);
275
+ }, [flash]);
276
+
277
+ useLayoutEffect(() => {
278
+ const el = scrollRef.current;
279
+ if (el === null) return;
280
+ if (anchorRef.current !== null) {
281
+ el.scrollTop = el.scrollHeight - anchorRef.current;
282
+ anchorRef.current = null;
283
+ return;
284
+ }
285
+ if (stickBottomRef.current) el.scrollTop = el.scrollHeight;
286
+ }, [entries]);
287
+
288
+ const onScroll = () => {
289
+ const el = scrollRef.current;
290
+ if (el === null) return;
291
+ stickBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 80;
292
+ };
293
+
294
+ const loadOlder = async () => {
295
+ if (channel === null || loadingOlder) return;
296
+ captureAnchor();
297
+ setLoadingOlder(true);
298
+ setOlderError(null);
299
+ try {
300
+ await channel.loadOlder();
301
+ } catch (error) {
302
+ anchorRef.current = null;
303
+ setOlderError(error);
304
+ trail?.recordEvent(
305
+ 'older-error',
306
+ error instanceof Error ? error.message : String(error),
307
+ channel.store.getState(),
308
+ );
309
+ } finally {
310
+ setLoadingOlder(false);
311
+ }
312
+ };
313
+
314
+ // Auto-paging: the top sentinel auto-loads the previous REST page when it
315
+ // approaches the viewport (paused while a previous load failed — the retry
316
+ // button re-arms it). This replaces any manual "load earlier" action.
317
+ const loadOlderRef = useRef(loadOlder);
318
+ loadOlderRef.current = loadOlder;
319
+ const topSentinelRef = useRef<HTMLDivElement>(null);
320
+ const hasMoreOlder = state.hasMoreOlder;
321
+ useEffect(() => {
322
+ const sentinel = topSentinelRef.current;
323
+ const root = scrollRef.current;
324
+ if (sentinel === null || root === null || olderError !== null) return;
325
+ const observer = new IntersectionObserver(
326
+ (observed) => {
327
+ if (observed.some((entry) => entry.isIntersecting)) void loadOlderRef.current();
328
+ },
329
+ { root, rootMargin: '400px 0px 0px 0px' },
330
+ );
331
+ observer.observe(sentinel);
332
+ return () => {
333
+ observer.disconnect();
334
+ };
335
+ }, [hasMoreOlder, loaded, olderError, loadingOlder]);
336
+
337
+ const running =
338
+ (state.sessionState !== undefined && state.sessionState.status !== 'idle') ||
339
+ isAnyTurnRunning(entries);
340
+ const pendingCount = [...state.interactions.values()].filter(
341
+ (interaction) => interaction.status === 'pending',
342
+ ).length;
343
+
344
+ // Interactions render inline at their anchor tool call; entities without
345
+ // an anchor (or whose anchor is outside the loaded window) collect here
346
+ // and render floating at the bottom. Unanchored tasks (no tool call
347
+ // references them, e.g. shell-command tasks) do the same.
348
+ const anchoredToolCallIds = useMemo(() => collectToolCallIds(entries), [entries]);
349
+ const unanchoredInteractions = [...state.interactions.values()].filter(
350
+ (interaction) =>
351
+ interaction.tool_call_id === undefined || !anchoredToolCallIds.has(interaction.tool_call_id),
352
+ );
353
+ const anchoredTaskIds = useMemo(() => collectTaskIds(entries), [entries]);
354
+ const unanchoredTasks = [...state.tasks.values()].filter(
355
+ (task) => !anchoredTaskIds.has(task.task_id),
356
+ );
357
+ const latestTodo = latestTodoOf(state.todos);
358
+
359
+ const cancel = async () => {
360
+ if (sessionId === null) return;
361
+ try {
362
+ await klient.session(sessionId).agent(agentId).service(IAgentLoopService).cancel(undefined);
363
+ trail?.recordEvent('cancel', undefined, state);
364
+ } catch (error) {
365
+ setSendError(error);
366
+ }
367
+ };
368
+
369
+ if (sessionId === null) {
370
+ return (
371
+ <div className="flex flex-1 items-center justify-center text-sm text-neutral-600">
372
+ Select a session on the left to open its conversation.
373
+ </div>
374
+ );
375
+ }
376
+ if (!ready) {
377
+ return (
378
+ <div className="flex flex-1 items-center justify-center text-sm text-neutral-600">
379
+ Loading session…
380
+ </div>
381
+ );
382
+ }
383
+
384
+ return (
385
+ <SessionContext.Provider value={sessionId}>
386
+ <div className="flex min-w-0 flex-1 flex-col">
387
+ <div className="flex items-center gap-2 border-b border-neutral-800 px-4 py-2">
388
+ <span className="font-mono text-[11px] text-neutral-400">{sessionId}</span>
389
+ <Badge tone="sky">agent: {agentId}</Badge>
390
+ {running ? <Badge tone="amber">turn running</Badge> : <Badge tone="green">idle</Badge>}
391
+ {pendingCount > 0 ? <Badge tone="amber">{pendingCount} pending</Badge> : null}
392
+ {state.sessionState !== undefined ? (
393
+ <SessionStateBadges sessionState={state.sessionState} />
394
+ ) : null}
395
+ </div>
396
+
397
+ <ChatSearchBar sessionId={sessionId} onOpenHit={onOpenSearchHit} />
398
+
399
+ <div className="flex-1 overflow-y-auto px-4 py-3" ref={scrollRef} onScroll={onScroll}>
400
+ {state.hasMoreOlder ? (
401
+ <div ref={topSentinelRef} className="mb-3 flex justify-center">
402
+ <span className="text-[11px] text-neutral-600">
403
+ {loadingOlder ? 'Loading earlier turns…' : ''}
404
+ </span>
405
+ </div>
406
+ ) : null}
407
+ {olderError !== null ? (
408
+ <div className="mb-2">
409
+ <ErrorLine error={olderError} />
410
+ <div className="mt-1 flex justify-center">
411
+ <ActionButton
412
+ onClick={() => {
413
+ setOlderError(null);
414
+ void loadOlder();
415
+ }}
416
+ >
417
+ Retry loading earlier turns
418
+ </ActionButton>
419
+ </div>
420
+ </div>
421
+ ) : null}
422
+ {loadError !== null ? (
423
+ <div className="mb-2">
424
+ <ErrorLine error={loadError} />
425
+ <div className="mt-1 text-[11px] text-neutral-600">
426
+ Failed to load the session history ��� the server may be too old to expose the
427
+ history API.
428
+ </div>
429
+ </div>
430
+ ) : null}
431
+ {entries.length === 0 && loadError === null ? (
432
+ <div className="text-[12px] text-neutral-600 italic">
433
+ {loaded ? 'Empty transcript.' : 'Loading transcript…'}
434
+ </div>
435
+ ) : null}
436
+ {latestTodo !== undefined && latestTodo.items.length > 0 ? (
437
+ <TodoCard todo={latestTodo} />
438
+ ) : null}
439
+ <Timeline items={entries} interactions={state.interactions} tasks={state.tasks} flash={flash} />
440
+ {unanchoredInteractions.map((interaction) => (
441
+ <InteractionEntityView key={interaction.interaction_id} interaction={interaction} />
442
+ ))}
443
+ {unanchoredTasks.map((task) => (
444
+ <TaskCard key={task.task_id} task={task} />
445
+ ))}
446
+ </div>
447
+
448
+ <div className="border-t border-neutral-800 p-3">
449
+ {sendError !== null ? (
450
+ <div className="mb-2">
451
+ <ErrorLine error={sendError} />
452
+ </div>
453
+ ) : null}
454
+ <div className="flex justify-end">
455
+ <ActionButton onClick={() => void cancel()} danger disabled={!running}>
456
+ Cancel
457
+ </ActionButton>
458
+ </div>
459
+ </div>
460
+ </div>
461
+ </SessionContext.Provider>
462
+ );
463
+ }
464
+
465
+ // ---------------------------------------------------------------- timeline
466
+
467
+ type RenderItem =
468
+ | {
469
+ readonly kind: 'group';
470
+ readonly turnId: string;
471
+ readonly turn: TurnMessage | undefined;
472
+ readonly items: readonly TimelineEntry[];
473
+ }
474
+ | { readonly kind: 'system'; readonly key: string; readonly message: SystemMessage };
475
+
476
+ /** Pseudo-turn grouping queued (unread) user messages, which carry no turn_id yet. */
477
+ const QUEUED_TURN_ID = '$queued';
478
+
479
+ function groupTimeline(entries: readonly TimelineEntry[]): RenderItem[] {
480
+ interface GroupDraft {
481
+ turn?: TurnMessage;
482
+ items: TimelineEntry[];
483
+ }
484
+ const drafts = new Map<string, GroupDraft>();
485
+ const order: (
486
+ | { kind: 'group'; turnId: string }
487
+ | { kind: 'system'; key: string; message: SystemMessage }
488
+ )[] = [];
489
+ for (const entry of entries) {
490
+ const message = entry.message;
491
+ if (message.type === 'system') {
492
+ order.push({ kind: 'system', key: entry.key, message });
493
+ continue;
494
+ }
495
+ const turnId = message.turn_id ?? QUEUED_TURN_ID;
496
+ let draft = drafts.get(turnId);
497
+ if (draft === undefined) {
498
+ draft = { items: [] };
499
+ drafts.set(turnId, draft);
500
+ order.push({ kind: 'group', turnId });
501
+ }
502
+ if (message.type === 'turn') draft.turn = message;
503
+ draft.items.push(entry);
504
+ }
505
+ return order.map((item) =>
506
+ item.kind === 'system'
507
+ ? item
508
+ : {
509
+ kind: 'group',
510
+ turnId: item.turnId,
511
+ turn: drafts.get(item.turnId)?.turn,
512
+ items: drafts.get(item.turnId)?.items ?? [],
513
+ },
514
+ );
515
+ }
516
+
517
+ function Timeline({
518
+ items,
519
+ interactions,
520
+ tasks,
521
+ flash,
522
+ }: {
523
+ items: readonly TimelineEntry[];
524
+ interactions: ReadonlyMap<string, InteractionMessage>;
525
+ tasks: ReadonlyMap<string, TaskMessage>;
526
+ flash?: { turnId: string; stepId?: string | undefined } | null | undefined;
527
+ }) {
528
+ const renderItems = useMemo(() => groupTimeline(items), [items]);
529
+ return (
530
+ <>
531
+ {renderItems.map((item) =>
532
+ item.kind === 'system' ? (
533
+ // Native virtual screen: the browser skips layout/paint for
534
+ // off-screen items and remembers their last rendered size.
535
+ <div
536
+ key={item.key}
537
+ style={{ contentVisibility: 'auto', containIntrinsicSize: 'auto 60px' }}
538
+ >
539
+ <SystemMarkerView message={item.message} />
540
+ </div>
541
+ ) : (
542
+ <div
543
+ key={`turn:${item.turnId}`}
544
+ style={{ contentVisibility: 'auto', containIntrinsicSize: 'auto 200px' }}
545
+ >
546
+ <TurnGroupView
547
+ turnId={item.turnId}
548
+ turn={item.turn}
549
+ items={item.items}
550
+ interactions={interactions}
551
+ tasks={tasks}
552
+ flash={flash}
553
+ />
554
+ </div>
555
+ ),
556
+ )}
557
+ </>
558
+ );
559
+ }
560
+
561
+ function isAnyTurnRunning(entries: readonly TimelineEntry[]): boolean {
562
+ return entries.some(
563
+ (entry) => entry.message.type === 'turn' && entry.message.status === 'running',
564
+ );
565
+ }
566
+
567
+ function collectToolCallIds(entries: readonly TimelineEntry[]): Set<string> {
568
+ const ids = new Set<string>();
569
+ for (const entry of entries) {
570
+ if (entry.message.type === 'tool_call') ids.add(entry.message.tool_call_id);
571
+ }
572
+ return ids;
573
+ }
574
+
575
+ function collectTaskIds(entries: readonly TimelineEntry[]): Set<string> {
576
+ const ids = new Set<string>();
577
+ for (const entry of entries) {
578
+ if (entry.message.type === 'tool_call' && entry.message.task_id !== undefined) {
579
+ ids.add(entry.message.task_id);
580
+ }
581
+ }
582
+ return ids;
583
+ }
584
+
585
+ function latestTodoOf(todos: ReadonlyMap<string, TodoMessage>): TodoMessage | undefined {
586
+ let latest: TodoMessage | undefined;
587
+ for (const todo of todos.values()) {
588
+ if (latest === undefined || todo.timestamp > latest.timestamp) latest = todo;
589
+ }
590
+ return latest;
591
+ }
592
+
593
+ // ---------------------------------------------------------------- turn group
594
+
595
+ function TurnGroupView({
596
+ turnId,
597
+ turn,
598
+ items,
599
+ interactions,
600
+ tasks,
601
+ flash,
602
+ }: {
603
+ turnId: string;
604
+ turn: TurnMessage | undefined;
605
+ items: readonly TimelineEntry[];
606
+ interactions: ReadonlyMap<string, InteractionMessage>;
607
+ tasks: ReadonlyMap<string, TaskMessage>;
608
+ flash?: { turnId: string; stepId?: string | undefined } | null | undefined;
609
+ }) {
610
+ const turnFlashed = flash?.turnId === turnId && flash.stepId === undefined;
611
+ const queued = turnId === QUEUED_TURN_ID;
612
+ return (
613
+ <div
614
+ data-turn-id={turnId}
615
+ className={`mb-3 rounded-lg border bg-neutral-900/30 ${
616
+ turnFlashed ? 'border-sky-600' : 'border-neutral-800'
617
+ }`}
618
+ >
619
+ <div className="flex items-center gap-2 border-b border-neutral-800/60 px-3 py-1.5">
620
+ {queued ? (
621
+ <Badge tone="amber">queued</Badge>
622
+ ) : (
623
+ <span className="font-mono text-[10px] text-neutral-500">{turnId}</span>
624
+ )}
625
+ {turn !== undefined ? (
626
+ <>
627
+ <Badge tone={turn.origin.kind === 'user' ? 'sky' : 'neutral'}>{turn.origin.kind}</Badge>
628
+ <Badge tone={turn.status === 'running' ? 'amber' : 'green'}>{turn.status}</Badge>
629
+ {turn.started_at !== undefined ? (
630
+ <span className="text-[10px] text-neutral-600">
631
+ {relTime(Date.parse(turn.started_at))}
632
+ </span>
633
+ ) : null}
634
+ {turn.usage !== undefined ? (
635
+ <span className="ml-auto text-[10px] text-neutral-600">
636
+ {turnUsageText(turn.usage)}
637
+ </span>
638
+ ) : null}
639
+ </>
640
+ ) : queued ? (
641
+ <span className="text-[10px] text-neutral-600 italic">not consumed into a turn yet</span>
642
+ ) : (
643
+ <span className="text-[10px] text-neutral-700 italic">turn header outside the window</span>
644
+ )}
645
+ </div>
646
+ <div className="px-3 py-2">
647
+ {turn?.attachment_ids !== undefined && turn.attachment_ids.length > 0 ? (
648
+ <AttachmentChips ids={turn.attachment_ids} />
649
+ ) : null}
650
+ {items.map((entry) => (
651
+ <TimelineEntryView
652
+ key={entry.key}
653
+ entry={entry}
654
+ interactions={interactions}
655
+ tasks={tasks}
656
+ flash={flash}
657
+ />
658
+ ))}
659
+ </div>
660
+ </div>
661
+ );
662
+ }
663
+
664
+ function turnUsageText(usage: NonNullable<TurnMessage['usage']>): string {
665
+ const parts: string[] = [];
666
+ if (usage.input_tokens !== undefined) parts.push(`in ${usage.input_tokens}`);
667
+ if (usage.output_tokens !== undefined) parts.push(`out ${usage.output_tokens}`);
668
+ if (usage.cached_tokens !== undefined) parts.push(`cached ${usage.cached_tokens}`);
669
+ if (usage.cost !== undefined) parts.push(`$${usage.cost.toFixed(4)}`);
670
+ return parts.join(' / ');
671
+ }
672
+
673
+ function TimelineEntryView({
674
+ entry,
675
+ interactions,
676
+ tasks,
677
+ flash,
678
+ }: {
679
+ entry: TimelineEntry;
680
+ interactions: ReadonlyMap<string, InteractionMessage>;
681
+ tasks: ReadonlyMap<string, TaskMessage>;
682
+ flash?: { turnId: string; stepId?: string | undefined } | null | undefined;
683
+ }) {
684
+ const message = entry.message;
685
+ switch (message.type) {
686
+ case 'turn':
687
+ return null;
688
+ case 'step':
689
+ return <StepRow step={message} flashed={flash?.stepId === message.step_id} />;
690
+ case 'user':
691
+ return <UserMessageView message={message} />;
692
+ case 'assistant':
693
+ return <AssistantMessageView message={message} />;
694
+ case 'thinking':
695
+ return <ThinkingMessageView message={message} />;
696
+ case 'tool_call':
697
+ return <ToolCallView call={message} interactions={interactions} tasks={tasks} />;
698
+ case 'system':
699
+ return <SystemMarkerView message={message} />;
700
+ }
701
+ }
702
+
703
+ function StepRow({ step, flashed }: { step: StepMessage; flashed: boolean }) {
704
+ return (
705
+ <div
706
+ data-step-id={step.step_id}
707
+ className={`mb-2 flex flex-wrap items-center gap-2 rounded px-1 py-0.5 text-[10px] text-neutral-600 ${
708
+ flashed ? 'bg-sky-900/20' : ''
709
+ }`}
710
+ >
711
+ <span className="font-mono">{step.step_id}</span>
712
+ <Badge
713
+ tone={
714
+ step.status === 'failed'
715
+ ? 'red'
716
+ : step.status === 'running'
717
+ ? 'amber'
718
+ : step.status === 'interrupted'
719
+ ? 'neutral'
720
+ : 'green'
721
+ }
722
+ >
723
+ {step.status}
724
+ </Badge>
725
+ {step.retry !== undefined ? (
726
+ <Badge tone="red">
727
+ retry {step.retry.failed_attempt}→{step.retry.next_attempt}/{step.retry.max_attempts}:{' '}
728
+ {step.retry.error_name}
729
+ </Badge>
730
+ ) : null}
731
+ {step.finish_reason !== undefined ? <span>finish: {step.finish_reason}</span> : null}
732
+ {step.usage !== undefined ? (
733
+ <span>
734
+ in {step.usage.input_other + step.usage.input_cache_read + step.usage.input_cache_creation}{' '}
735
+ / out {step.usage.output}
736
+ </span>
737
+ ) : null}
738
+ {step.end_reason !== undefined ? <span className="italic">{step.end_reason}</span> : null}
739
+ {step.end_message !== undefined ? <span className="italic">{step.end_message}</span> : null}
740
+ </div>
741
+ );
742
+ }
743
+
744
+ // ---------------------------------------------------------------- messages
745
+
746
+ function UserMessageView({ message }: { message: UserMessage }) {
747
+ const isUserInput = message.origin === undefined || message.origin.kind === 'user';
748
+ return (
749
+ <div className="mb-2">
750
+ <div className="mb-0.5 flex items-center gap-2 text-[10px] text-neutral-600">
751
+ <span className="font-mono">{message.message_id}</span>
752
+ {message.origin !== undefined && message.origin.kind !== 'user' ? (
753
+ <Badge tone="neutral">{userOriginLabel(message.origin)}</Badge>
754
+ ) : null}
755
+ {message.status === 'unread' ? <span className="italic">queued</span> : null}
756
+ </div>
757
+ {isUserInput ? (
758
+ <div className="flex justify-end">
759
+ <div className="max-w-[80%] whitespace-pre-wrap rounded-lg bg-sky-900/40 px-3 py-2 text-[13px] text-neutral-100">
760
+ <UserContentParts parts={message.text} />
761
+ </div>
762
+ </div>
763
+ ) : (
764
+ <div className="whitespace-pre-wrap rounded-lg border border-neutral-800 px-3 py-2 text-[12px] text-neutral-400">
765
+ <UserContentParts parts={message.text} />
766
+ </div>
767
+ )}
768
+ {message.attachment_ids !== undefined && message.attachment_ids.length > 0 ? (
769
+ <AttachmentChips ids={message.attachment_ids} />
770
+ ) : null}
771
+ {message.skill_activations !== undefined && message.skill_activations.length > 0 ? (
772
+ <div className="mt-1 flex flex-wrap gap-1">
773
+ {message.skill_activations.map((skill) => (
774
+ <Badge key={skill.skill_name} tone="violet">
775
+ skill: {skill.skill_name}
776
+ </Badge>
777
+ ))}
778
+ </div>
779
+ ) : null}
780
+ </div>
781
+ );
782
+ }
783
+
784
+ function userOriginLabel(origin: Exclude<UserMessage['origin'], undefined>): string {
785
+ switch (origin.kind) {
786
+ case 'user':
787
+ return 'user';
788
+ case 'cron':
789
+ return `cron ${origin.cron_id ?? ''}`.trim();
790
+ case 'task':
791
+ return `task: ${origin.title}`;
792
+ case 'skill':
793
+ return `skill: ${origin.skill_name}`;
794
+ }
795
+ }
796
+
797
+ function UserContentParts({ parts }: { parts: readonly ContentPart[] }) {
798
+ const text = parts
799
+ .filter((part) => part.type === 'text' || part.type === 'think')
800
+ .map((part) => part.text)
801
+ .join('\n');
802
+ const media = parts.filter(
803
+ (part) => part.type === 'image' || part.type === 'audio' || part.type === 'video',
804
+ );
805
+ return (
806
+ <>
807
+ {text}
808
+ {media.length > 0 ? (
809
+ <span className="mt-1 flex flex-wrap gap-1">
810
+ {media.map((part, index) => (
811
+ <span
812
+ key={index}
813
+ title={part.text}
814
+ className="rounded border border-neutral-700 bg-neutral-900 px-1.5 py-0.5 text-[10px] text-neutral-400"
815
+ >
816
+ {part.type}: {mediaPartLabel(part)}
817
+ </span>
818
+ ))}
819
+ </span>
820
+ ) : null}
821
+ </>
822
+ );
823
+ }
824
+
825
+ function mediaPartLabel(part: ContentPart): string {
826
+ const name = part.meta['name'];
827
+ if (typeof name === 'string' && name !== '') return name;
828
+ const id = part.meta['id'];
829
+ if (typeof id === 'string' && id !== '') return id;
830
+ return part.text;
831
+ }
832
+
833
+ function AssistantMessageView({ message }: { message: AssistantMessage }) {
834
+ return (
835
+ <div className="mb-2 max-w-[85%]">
836
+ <div className="whitespace-pre-wrap rounded-lg bg-neutral-800/60 px-3 py-2 text-[13px] text-neutral-100">
837
+ {message.text}
838
+ {message.status === 'streaming' ? <span className="text-neutral-500"> ▍</span> : null}
839
+ </div>
840
+ </div>
841
+ );
842
+ }
843
+
844
+ function ThinkingMessageView({ message }: { message: ThinkingMessage }) {
845
+ return (
846
+ <div className="mb-2 max-w-[85%] whitespace-pre-wrap rounded-lg border border-dashed border-neutral-700 px-3 py-2 font-mono text-[11px] text-neutral-500">
847
+ {message.text}
848
+ {message.status === 'streaming' ? <span> ▍</span> : null}
849
+ </div>
850
+ );
851
+ }
852
+
853
+ function AttachmentChips({ ids }: { ids: readonly string[] }) {
854
+ return (
855
+ <div className="mb-2 flex flex-wrap gap-1">
856
+ {ids.map((id) => (
857
+ <span
858
+ key={id}
859
+ className="rounded border border-neutral-700 bg-neutral-900 px-2 py-0.5 text-[10px] text-neutral-400"
860
+ >
861
+ 📎 {id}
862
+ </span>
863
+ ))}
864
+ </div>
865
+ );
866
+ }
867
+
868
+ // ---------------------------------------------------------------- tool calls
869
+
870
+ function ToolCallView({
871
+ call,
872
+ interactions,
873
+ tasks,
874
+ }: {
875
+ call: ToolCallMessage;
876
+ interactions: ReadonlyMap<string, InteractionMessage>;
877
+ tasks: ReadonlyMap<string, TaskMessage>;
878
+ }) {
879
+ const task = call.task_id !== undefined ? tasks.get(call.task_id) : undefined;
880
+ const linked = [...interactions.values()].filter(
881
+ (interaction) =>
882
+ interaction.interaction_id === call.approval_id ||
883
+ interaction.tool_call_id === call.tool_call_id,
884
+ );
885
+ return (
886
+ <div className="mb-2 max-w-[85%] rounded-lg border border-neutral-800 bg-neutral-900/50 px-3 py-2 font-mono text-[11px]">
887
+ <div className="mb-1 flex flex-wrap items-center gap-2">
888
+ <Badge
889
+ tone={call.status === 'error' ? 'red' : call.status === 'running' ? 'amber' : 'neutral'}
890
+ >
891
+ tool
892
+ </Badge>
893
+ <span className="text-neutral-300">{call.name}</span>
894
+ <span className="text-neutral-600 select-all">{call.tool_call_id}</span>
895
+ {call.view !== undefined && call.view !== call.name ? (
896
+ <span className="text-neutral-600">view: {call.view}</span>
897
+ ) : null}
898
+ {call.agent_refs?.map((ref) => (
899
+ <Badge key={ref.agent_id} tone="sky">
900
+ agent: {ref.agent_id}
901
+ </Badge>
902
+ ))}
903
+ {task !== undefined ? <span className="text-neutral-600">task: {task.status}</span> : null}
904
+ {call.todo_id !== undefined ? (
905
+ <span className="text-neutral-600">todo: {call.todo_id}</span>
906
+ ) : null}
907
+ </div>
908
+ {call.input !== undefined ? (
909
+ typeof call.input === 'string' ? (
910
+ <pre className="max-h-32 overflow-auto whitespace-pre-wrap text-neutral-500">
911
+ {call.input}
912
+ </pre>
913
+ ) : (
914
+ <JsonView data={call.input} />
915
+ )
916
+ ) : call.input_text !== undefined && call.input_text !== '' ? (
917
+ <pre className="max-h-32 overflow-auto whitespace-pre-wrap text-neutral-500">
918
+ {call.input_text}
919
+ </pre>
920
+ ) : null}
921
+ {call.output !== undefined ? (
922
+ typeof call.output === 'string' ? (
923
+ <pre
924
+ className={`max-h-40 overflow-auto whitespace-pre-wrap ${
925
+ call.status === 'error' ? 'text-red-400' : 'text-neutral-400'
926
+ }`}
927
+ >
928
+ {call.output}
929
+ </pre>
930
+ ) : (
931
+ <JsonView data={call.output} />
932
+ )
933
+ ) : task !== undefined && task.output_tail !== '' ? (
934
+ <pre className="max-h-40 overflow-auto whitespace-pre-wrap text-neutral-400">
935
+ {task.output_tail}
936
+ </pre>
937
+ ) : null}
938
+ {call.error !== undefined && call.error !== call.output ? (
939
+ <pre className="max-h-40 overflow-auto whitespace-pre-wrap text-red-400">{call.error}</pre>
940
+ ) : null}
941
+ {call.progress !== undefined ? (
942
+ <div className="mt-1 text-neutral-600">
943
+ progress ({call.progress.kind}):{' '}
944
+ {call.progress.text ?? (call.progress.percent !== undefined ? `${call.progress.percent}%` : call.progress.custom_kind ?? '')}
945
+ </div>
946
+ ) : null}
947
+ {linked.map((interaction) => (
948
+ <InteractionEntityView key={interaction.interaction_id} interaction={interaction} nested />
949
+ ))}
950
+ </div>
951
+ );
952
+ }
953
+
954
+ // ---------------------------------------------------------------- interactions
955
+
956
+ function InteractionEntityView({
957
+ interaction,
958
+ nested,
959
+ }: {
960
+ interaction: InteractionMessage;
961
+ nested?: boolean;
962
+ }) {
963
+ const { baseUrl, config } = useConnection();
964
+ const sessionId = useContext(SessionContext);
965
+ const [busy, setBusy] = useState(false);
966
+ const [respondError, setRespondError] = useState<unknown>(null);
967
+ /** Question answers in progress: question id → selected option labels. */
968
+ const [selections, setSelections] = useState<Readonly<Record<string, readonly string[]>>>({});
969
+ /** Question free-text ("Other") input: question id → draft. */
970
+ const [others, setOthers] = useState<Readonly<Record<string, string>>>({});
971
+
972
+ const pending = interaction.status === 'pending';
973
+ const questionRequest = interaction.kind === 'question' ? interaction.request : undefined;
974
+ const api = { baseUrl, token: config.token, sessionId };
975
+
976
+ const run = (fn: () => Promise<unknown>): void => {
977
+ setBusy(true);
978
+ setRespondError(null);
979
+ void fn()
980
+ .catch((error: unknown) => {
981
+ setRespondError(error);
982
+ })
983
+ .finally(() => {
984
+ setBusy(false);
985
+ });
986
+ };
987
+
988
+ const decide = (decision: 'approved' | 'rejected'): void => {
989
+ run(() => decideApproval(api, interaction.interaction_id, decision));
990
+ };
991
+
992
+ const toggleOption = (question: InteractionQuestionItem, label: string): void => {
993
+ setSelections((prev) => {
994
+ const current = prev[question.id] ?? [];
995
+ const next =
996
+ question.multi_select === true
997
+ ? current.includes(label)
998
+ ? current.filter((item) => item !== label)
999
+ : [...current, label]
1000
+ : current.includes(label)
1001
+ ? []
1002
+ : [label];
1003
+ return { ...prev, [question.id]: next };
1004
+ });
1005
+ };
1006
+
1007
+ const submitAnswers = (): void => {
1008
+ const answers: Record<string, QuestionAnswerWire> = {};
1009
+ for (const question of questionRequest?.questions ?? []) {
1010
+ const selected = selections[question.id] ?? [];
1011
+ const optionIds = selected.flatMap((label) => {
1012
+ const match = question.options.find((option) => option.label === label);
1013
+ return match === undefined ? [] : [match.id];
1014
+ });
1015
+ const other = (others[question.id] ?? '').trim();
1016
+ if (other !== '' && optionIds.length > 0) {
1017
+ answers[question.id] = {
1018
+ kind: 'multi_with_other',
1019
+ option_ids: optionIds,
1020
+ other_text: other,
1021
+ };
1022
+ } else if (other !== '') {
1023
+ answers[question.id] = { kind: 'other', text: other };
1024
+ } else if (optionIds.length > 1 || (question.multi_select === true && optionIds.length > 0)) {
1025
+ answers[question.id] = { kind: 'multi', option_ids: optionIds };
1026
+ } else if (optionIds.length === 1) {
1027
+ answers[question.id] = { kind: 'single', option_id: optionIds[0]! };
1028
+ }
1029
+ }
1030
+ // Mirror the TUI adapter: no answers at all dismisses the question.
1031
+ if (Object.keys(answers).length === 0) {
1032
+ dismiss();
1033
+ return;
1034
+ }
1035
+ run(() => answerQuestion(api, interaction.interaction_id, answers, 'enter'));
1036
+ };
1037
+
1038
+ const dismiss = (): void => {
1039
+ run(() => dismissQuestion(api, interaction.interaction_id));
1040
+ };
1041
+
1042
+ return (
1043
+ <div
1044
+ className={`mb-2 max-w-[85%] rounded-lg border border-amber-900/50 bg-amber-950/20 px-3 py-2 text-[11px] ${
1045
+ nested === true ? 'mt-2 max-w-full' : ''
1046
+ }`}
1047
+ >
1048
+ <div className="mb-1 flex items-center gap-2">
1049
+ <Badge tone={pending ? 'amber' : 'neutral'}>{interaction.kind}</Badge>
1050
+ <span className="text-neutral-400">{interaction.status}</span>
1051
+ <span className="text-neutral-600">tool: {interaction.tool_call_id}</span>
1052
+ </div>
1053
+ {interaction.request !== undefined && questionRequest === undefined ? (
1054
+ <JsonView data={interaction.request} />
1055
+ ) : null}
1056
+ {questionRequest !== undefined && !pending ? <JsonView data={questionRequest} /> : null}
1057
+ {interaction.response !== undefined ? <JsonView data={interaction.response} /> : null}
1058
+ {pending && interaction.kind === 'approval' ? (
1059
+ <div className="mt-2 flex gap-2">
1060
+ <ActionButton onClick={() => decide('approved')} disabled={busy}>
1061
+ Approve
1062
+ </ActionButton>
1063
+ <ActionButton onClick={() => decide('rejected')} danger disabled={busy}>
1064
+ Reject
1065
+ </ActionButton>
1066
+ </div>
1067
+ ) : null}
1068
+ {pending && questionRequest !== undefined ? (
1069
+ <div className="mt-2">
1070
+ {questionRequest.questions.map((question) => (
1071
+ <div key={question.id} className="mb-2">
1072
+ <div className="text-neutral-300">{question.header ?? question.question}</div>
1073
+ <div className="mt-1 flex flex-wrap gap-1">
1074
+ {question.options.map((option) => {
1075
+ const selected = (selections[question.id] ?? []).includes(option.label);
1076
+ return (
1077
+ <button
1078
+ key={option.id}
1079
+ className={`rounded border px-2 py-0.5 text-[10px] transition-colors disabled:opacity-40 ${
1080
+ selected
1081
+ ? 'border-sky-600 bg-sky-900/50 text-sky-200'
1082
+ : 'border-neutral-700 text-neutral-400 hover:bg-neutral-800'
1083
+ }`}
1084
+ title={option.description}
1085
+ disabled={busy}
1086
+ onClick={() => toggleOption(question, option.label)}
1087
+ >
1088
+ {option.label}
1089
+ </button>
1090
+ );
1091
+ })}
1092
+ </div>
1093
+ <input
1094
+ className="mt-1 w-full rounded border border-neutral-700 bg-neutral-950 px-2 py-1 text-[11px] text-neutral-100 outline-none focus:border-sky-600"
1095
+ placeholder={question.other_label ?? 'Other…'}
1096
+ value={others[question.id] ?? ''}
1097
+ disabled={busy}
1098
+ onChange={(e) => {
1099
+ setOthers((prev) => ({ ...prev, [question.id]: e.target.value }));
1100
+ }}
1101
+ />
1102
+ </div>
1103
+ ))}
1104
+ <div className="flex gap-2">
1105
+ <ActionButton onClick={submitAnswers} disabled={busy}>
1106
+ Answer
1107
+ </ActionButton>
1108
+ <ActionButton onClick={dismiss} danger disabled={busy}>
1109
+ Dismiss
1110
+ </ActionButton>
1111
+ </div>
1112
+ </div>
1113
+ ) : null}
1114
+ {respondError !== null ? (
1115
+ <div className="mt-2">
1116
+ <ErrorLine error={respondError} />
1117
+ </div>
1118
+ ) : null}
1119
+ </div>
1120
+ );
1121
+ }
1122
+
1123
+ // ---------------------------------------------------------------- state entities
1124
+
1125
+ function SystemMarkerView({ message }: { message: SystemMessage }) {
1126
+ return (
1127
+ <div className="mb-3">
1128
+ <div className="flex items-center gap-2 text-[10px] text-neutral-600">
1129
+ <div className="h-px flex-1 bg-neutral-800" />
1130
+ <span className="font-mono">system({message.subtype})</span>
1131
+ <span className="font-mono text-neutral-700">{message.system_id}</span>
1132
+ {message.at !== undefined ? <span>{relTime(Date.parse(message.at))}</span> : null}
1133
+ <div className="h-px flex-1 bg-neutral-800" />
1134
+ </div>
1135
+ {message.payload !== undefined ? <JsonView data={message.payload} /> : null}
1136
+ </div>
1137
+ );
1138
+ }
1139
+
1140
+ function TaskCard({ task }: { task: TaskMessage }) {
1141
+ const failed =
1142
+ task.status === 'failed' || task.status === 'timed_out' || task.status === 'lost';
1143
+ return (
1144
+ <div className="mb-3 rounded-lg border border-neutral-800 bg-neutral-900/40 px-3 py-2 text-[11px]">
1145
+ <div className="flex items-center gap-2">
1146
+ <Badge tone={task.status === 'running' ? 'amber' : failed ? 'red' : 'neutral'}>
1147
+ task: {task.kind}
1148
+ </Badge>
1149
+ <span className="text-neutral-300">{task.description ?? task.task_id}</span>
1150
+ <span className="text-neutral-600">
1151
+ {task.status}
1152
+ {task.detached ? ' (detached)' : ''}
1153
+ </span>
1154
+ {task.child_agent_id !== undefined ? (
1155
+ <Badge tone="sky">agent: {task.child_agent_id}</Badge>
1156
+ ) : null}
1157
+ </div>
1158
+ {task.output_tail !== '' ? (
1159
+ <pre className="mt-1 max-h-32 overflow-auto whitespace-pre-wrap text-neutral-500">
1160
+ {task.output_tail}
1161
+ </pre>
1162
+ ) : null}
1163
+ {task.error !== undefined ? (
1164
+ <pre className="mt-1 max-h-32 overflow-auto whitespace-pre-wrap text-red-400">
1165
+ {task.error}
1166
+ </pre>
1167
+ ) : null}
1168
+ {task.result_summary !== undefined ? (
1169
+ <div className="mt-1 text-neutral-500">{task.result_summary}</div>
1170
+ ) : null}
1171
+ </div>
1172
+ );
1173
+ }
1174
+
1175
+ function TodoCard({ todo }: { todo: TodoMessage }) {
1176
+ return (
1177
+ <div className="mb-3 rounded-lg border border-neutral-800 bg-neutral-900/40 px-3 py-2 text-[11px]">
1178
+ <div className="mb-1 text-neutral-500">todo (latest)</div>
1179
+ {todo.items.map((entry, i) => (
1180
+ <div key={i} className="flex gap-2">
1181
+ <span
1182
+ className={
1183
+ entry.status === 'done'
1184
+ ? 'text-green-500'
1185
+ : entry.status === 'in_progress'
1186
+ ? 'text-sky-400'
1187
+ : 'text-neutral-600'
1188
+ }
1189
+ >
1190
+ {entry.status === 'done' ? '✔' : entry.status === 'in_progress' ? '◐' : '□'}
1191
+ </span>
1192
+ <span
1193
+ className={entry.status === 'done' ? 'text-neutral-600 line-through' : 'text-neutral-300'}
1194
+ >
1195
+ {entry.title}
1196
+ </span>
1197
+ </div>
1198
+ ))}
1199
+ </div>
1200
+ );
1201
+ }
1202
+
1203
+ function SessionStateBadges({ sessionState }: { sessionState: SessionStateMessage }) {
1204
+ return (
1205
+ <>
1206
+ {sessionState.pending_interaction !== undefined &&
1207
+ sessionState.pending_interaction !== 'none' ? (
1208
+ <Badge tone="amber">{sessionState.pending_interaction}</Badge>
1209
+ ) : null}
1210
+ {sessionState.model !== undefined ? <Badge tone="neutral">{sessionState.model}</Badge> : null}
1211
+ {sessionState.permission !== undefined ? (
1212
+ <Badge tone="neutral">perm: {sessionState.permission}</Badge>
1213
+ ) : null}
1214
+ {sessionState.modes?.plan !== undefined ? <Badge tone="violet">plan mode</Badge> : null}
1215
+ {sessionState.modes?.swarm !== undefined ? <Badge tone="violet">swarm</Badge> : null}
1216
+ {sessionState.goal !== undefined ? (
1217
+ <Badge tone={sessionState.goal.status === 'active' ? 'sky' : 'neutral'}>
1218
+ goal: {sessionState.goal.status}
1219
+ </Badge>
1220
+ ) : null}
1221
+ {sessionState.context_tokens !== undefined ? (
1222
+ <span className="text-[10px] text-neutral-600">
1223
+ ctx {sessionState.context_tokens}
1224
+ {sessionState.max_context_tokens !== undefined
1225
+ ? `/${sessionState.max_context_tokens}`
1226
+ : ''}
1227
+ </span>
1228
+ ) : null}
1229
+ </>
1230
+ );
1231
+ }
apps/kimi-inspect/src/components/DiInspectionView.tsx ADDED
@@ -0,0 +1,635 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * DI view — the engine's "Service × Effect × DI" debug surface (the App-scope
3
+ * `debug` domain, exposed like every other Service over `/api/v1/debug`):
4
+ *
5
+ * - Units: the unit tree = ledger tree (`IDebugLedgerService.tree`) — one
6
+ * collapsible card per scope container, its units with the five-state
7
+ * cascade badge and the unprovide / update / dispose triggers
8
+ * (`IDebugCascadeService`), its ledger entries as a nested subtree;
9
+ * - Deps: the persistent dependency DAG (`IDebugGraphService.graph`) as
10
+ * root-based Miller columns (`di/DiGraphPanel.tsx`) — column 0 lists the
11
+ * roots (services nothing consumes), each click opens the next column
12
+ * with that service's direct dependencies; rows carry path-scoped
13
+ * relation bars (one per path ancestor with a direct edge) and a
14
+ * path-root background highlight;
15
+ * - Events: event subscriptions (`IDebugEventsService.subscriptions`) —
16
+ * unit-book ledger entries labeled `on:<name>` /
17
+ * `disposable:EventSubscription` per scope, plus per-bus listener counts
18
+ * as the fallback side (`di/DiEventsPanel.tsx`);
19
+ * - Cascade: the cross-scope cascade history rings
20
+ * (`IDebugCascadeService.history`), newest first;
21
+ * - Pending: the waiting area + sticky failures per scope
22
+ * (`IDebugCascadeService.pending`), with an `update` retry per failure.
23
+ *
24
+ * All five panels poll on a short interval and refresh eagerly when the
25
+ * global `event.di.unit_changed` WS frame fires (`useDiQueryInvalidation`
26
+ * invalidates the `['di']` query prefix).
27
+ */
28
+
29
+ import type { UnitState } from '@moonshot-ai/agent-core-v2/_base/di/cascadeEngine';
30
+ import type { LedgerEntryInfo } from '@moonshot-ai/agent-core-v2/_base/lifecycle/ledger';
31
+ import {
32
+ IDebugCascadeService,
33
+ type DebugCascadeEntry,
34
+ type DebugPendingGroup,
35
+ } from '@moonshot-ai/agent-core-v2/debug/debugCascade';
36
+ import { IDebugGraphService, type DebugGraph } from '@moonshot-ai/agent-core-v2/debug/debugGraph';
37
+ import {
38
+ IDebugEventsService,
39
+ type DebugEventSubscriptions,
40
+ } from '@moonshot-ai/agent-core-v2/features/debugEvents/debugEvents';
41
+ import {
42
+ IDebugLedgerService,
43
+ type DebugLedgerNode,
44
+ type DebugUnit,
45
+ } from '@moonshot-ai/agent-core-v2/debug/debugLedger';
46
+ import { useQuery, useQueryClient } from '@tanstack/react-query';
47
+ import { useState, type ReactNode } from 'react';
48
+
49
+ import { useDiQueryInvalidation } from '../activity/di';
50
+ import type { InspectClient } from '../channel';
51
+ import { useConnection } from '../connection';
52
+ import { ActionButton, Badge, ErrorLine } from '../ui';
53
+ import { DiEventsPanel } from './di/DiEventsPanel';
54
+ import { DiGraphPanel } from './di/DiGraphPanel';
55
+
56
+ type DiPanel = 'units' | 'graph' | 'events' | 'cascade' | 'pending';
57
+
58
+ const PANELS: readonly { id: DiPanel; title: string }[] = [
59
+ { id: 'units', title: 'Units' },
60
+ { id: 'graph', title: 'Deps' },
61
+ { id: 'events', title: 'Events' },
62
+ { id: 'cascade', title: 'Cascade' },
63
+ { id: 'pending', title: 'Pending' },
64
+ ];
65
+
66
+ const REFETCH_INTERVAL_MS = 3000;
67
+
68
+ export function DiInspectionView() {
69
+ useDiQueryInvalidation();
70
+ const [panel, setPanel] = useState<DiPanel>('units');
71
+ return (
72
+ <div className="flex min-h-0 min-w-0 flex-1">
73
+ <nav className="w-32 shrink-0 border-r border-neutral-800 py-2">
74
+ <div className="px-3 pt-1 pb-2 text-[10px] font-semibold uppercase tracking-wider text-neutral-500">
75
+ DI
76
+ </div>
77
+ {PANELS.map((p) => (
78
+ <button
79
+ key={p.id}
80
+ type="button"
81
+ onClick={() => {
82
+ setPanel(p.id);
83
+ }}
84
+ className={`block w-full px-3 py-1 text-left text-[11px] transition-colors ${
85
+ panel === p.id
86
+ ? 'bg-neutral-800 text-sky-400'
87
+ : 'text-neutral-500 hover:bg-neutral-800/60 hover:text-neutral-300'
88
+ }`}
89
+ >
90
+ {p.title}
91
+ </button>
92
+ ))}
93
+ </nav>
94
+ <div className="min-w-0 flex-1 overflow-auto px-4 py-3">
95
+ {panel === 'units' ? (
96
+ <UnitsPanel />
97
+ ) : panel === 'graph' ? (
98
+ <GraphPanel />
99
+ ) : panel === 'events' ? (
100
+ <EventsPanel />
101
+ ) : panel === 'cascade' ? (
102
+ <CascadePanel />
103
+ ) : (
104
+ <PendingPanel />
105
+ )}
106
+ </div>
107
+ </div>
108
+ );
109
+ }
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // Shared data plumbing
113
+ // ---------------------------------------------------------------------------
114
+
115
+ function useDiQuery<T>(panel: string, fetcher: (klient: InspectClient) => Promise<T>) {
116
+ const { klient } = useConnection();
117
+ return useQuery({
118
+ queryKey: ['di', klient.baseUrl, panel],
119
+ queryFn: () => fetcher(klient),
120
+ refetchInterval: REFETCH_INTERVAL_MS,
121
+ });
122
+ }
123
+
124
+ /** The loading / error gate shared by every panel. Returns null when data is ready. */
125
+ function panelGate(query: { isError: boolean; error: unknown; data: unknown }): ReactNode | null {
126
+ if (query.isError) return <ErrorLine error={query.error} />;
127
+ if (query.data === undefined) {
128
+ return <div className="text-[11px] text-neutral-600 italic">loading…</div>;
129
+ }
130
+ return null;
131
+ }
132
+
133
+ type DiTriggerAction = 'unprovide' | 'update' | 'dispose';
134
+
135
+ interface DiTrigger {
136
+ /** `${action}:${scopePath}:${token}` of the in-flight trigger, if any. */
137
+ readonly busy: string | null;
138
+ readonly error: unknown;
139
+ readonly run: (action: DiTriggerAction, scopePath: string, token: string) => Promise<void>;
140
+ }
141
+
142
+ function triggerKey(action: DiTriggerAction, scopePath: string, token: string): string {
143
+ return `${action}:${scopePath}:${token}`;
144
+ }
145
+
146
+ /**
147
+ * The destructive unit triggers (`IDebugCascadeService.unprovide` / `update`
148
+ * / `dispose`), confirm-gated like the ServiceCard danger actions; a settled
149
+ * trigger invalidates the `['di']` prefix so every panel converges.
150
+ */
151
+ function useDiTrigger(): DiTrigger {
152
+ const { klient } = useConnection();
153
+ const queryClient = useQueryClient();
154
+ const [busy, setBusy] = useState<string | null>(null);
155
+ const [error, setError] = useState<unknown>(null);
156
+
157
+ const run = async (action: DiTriggerAction, scopePath: string, token: string): Promise<void> => {
158
+ if (!window.confirm(`${action} '${token}' @ ${scopePath}?`)) return;
159
+ setBusy(triggerKey(action, scopePath, token));
160
+ setError(null);
161
+ try {
162
+ const svc = klient.core(IDebugCascadeService);
163
+ if (action === 'unprovide') await svc.unprovide(scopePath, token);
164
+ else if (action === 'update') await svc.update(scopePath, token);
165
+ else await svc.dispose(scopePath, token);
166
+ await queryClient.invalidateQueries({ queryKey: ['di'] });
167
+ } catch (error) {
168
+ setError(error);
169
+ } finally {
170
+ setBusy(null);
171
+ }
172
+ };
173
+
174
+ return { busy, error, run };
175
+ }
176
+
177
+ function truncate(text: string, max: number): string {
178
+ return text.length > max ? `${text.slice(0, max - 1)}…` : text;
179
+ }
180
+
181
+ // ---------------------------------------------------------------------------
182
+ // Units panel — the unit tree = ledger tree
183
+ // ---------------------------------------------------------------------------
184
+
185
+ const STATE_TONES: Record<UnitState, 'neutral' | 'sky' | 'green' | 'amber' | 'red'> = {
186
+ Pending: 'neutral',
187
+ Activating: 'sky',
188
+ Active: 'green',
189
+ Unloading: 'amber',
190
+ Failed: 'red',
191
+ };
192
+
193
+ function UnitStateBadge({ state }: { state?: UnitState }) {
194
+ if (state === undefined) return <Badge tone="neutral">unseen</Badge>;
195
+ return <Badge tone={STATE_TONES[state]}>{state}</Badge>;
196
+ }
197
+
198
+ function UnitsPanel() {
199
+ const query = useDiQuery('units', (klient) => klient.core(IDebugLedgerService).tree());
200
+ const trigger = useDiTrigger();
201
+ const gate = panelGate(query);
202
+ if (gate !== null) return gate;
203
+ const tree = query.data as DebugLedgerNode;
204
+ return (
205
+ <div>
206
+ {trigger.error !== null ? (
207
+ <div className="mb-2">
208
+ <ErrorLine error={trigger.error} />
209
+ </div>
210
+ ) : null}
211
+ <ScopeNodeCard node={tree} depth={0} trigger={trigger} />
212
+ </div>
213
+ );
214
+ }
215
+
216
+ function ScopeNodeCard({
217
+ node,
218
+ depth,
219
+ trigger,
220
+ }: {
221
+ node: DebugLedgerNode;
222
+ depth: number;
223
+ trigger: DiTrigger;
224
+ }) {
225
+ const [open, setOpen] = useState(depth < 2);
226
+ const counts = new Map<UnitState, number>();
227
+ for (const unit of node.units) {
228
+ if (unit.state === undefined) continue;
229
+ counts.set(unit.state, (counts.get(unit.state) ?? 0) + 1);
230
+ }
231
+ return (
232
+ <div className={depth === 0 ? '' : 'ml-3 border-l border-neutral-800/60 pl-2'}>
233
+ <div className="mb-2 rounded-lg border border-neutral-800 bg-neutral-900/60">
234
+ <div
235
+ className="flex cursor-pointer items-center gap-2 px-3 py-2 select-none"
236
+ onClick={() => {
237
+ setOpen((v) => !v);
238
+ }}
239
+ >
240
+ <span className="shrink-0 text-[10px] text-neutral-600">{open ? '▾' : '▸'}</span>
241
+ <span className="shrink-0 text-[12px] font-medium text-neutral-200">{node.label}</span>
242
+ <span
243
+ className="min-w-0 truncate font-mono text-[10px] text-neutral-600"
244
+ title={node.path}
245
+ >
246
+ {node.path}
247
+ </span>
248
+ <span className="ml-auto flex shrink-0 items-center gap-1">
249
+ <span className="text-[10px] text-neutral-600">{node.units.length} units</span>
250
+ {[...counts.entries()].map(([state, count]) => (
251
+ <Badge key={state} tone={STATE_TONES[state]}>
252
+ {count} {state}
253
+ </Badge>
254
+ ))}
255
+ </span>
256
+ </div>
257
+ {open ? (
258
+ <div className="border-t border-neutral-800/60 px-3 py-2">
259
+ {node.units.length === 0 ? (
260
+ <div className="text-[11px] text-neutral-600 italic">no units</div>
261
+ ) : (
262
+ node.units.map((unit) => (
263
+ <UnitRow key={unit.token} scopePath={node.path} unit={unit} trigger={trigger} />
264
+ ))
265
+ )}
266
+ <LedgerSection entries={node.ledger} />
267
+ </div>
268
+ ) : null}
269
+ </div>
270
+ {open
271
+ ? node.children.map((child) => (
272
+ <ScopeNodeCard key={child.path} node={child} depth={depth + 1} trigger={trigger} />
273
+ ))
274
+ : null}
275
+ </div>
276
+ );
277
+ }
278
+
279
+ function UnitRow({
280
+ scopePath,
281
+ unit,
282
+ trigger,
283
+ }: {
284
+ scopePath: string;
285
+ unit: DebugUnit;
286
+ trigger: DiTrigger;
287
+ }) {
288
+ const [errorOpen, setErrorOpen] = useState(false);
289
+ const actions: readonly DiTriggerAction[] = ['unprovide', 'update', 'dispose'];
290
+ return (
291
+ <div className="mb-1 rounded border border-neutral-800/70 bg-neutral-950/40 px-2 py-1.5">
292
+ <div className="flex items-center gap-2">
293
+ <span
294
+ className="min-w-0 truncate font-mono text-[11px] text-neutral-200"
295
+ title={unit.token}
296
+ >
297
+ {unit.token}
298
+ </span>
299
+ <span title={unit.everActive === undefined ? '' : `everActive: ${unit.everActive}`}>
300
+ <UnitStateBadge state={unit.state} />
301
+ </span>
302
+ {unit.inFlight === true ? <Badge tone="sky">in-flight</Badge> : null}
303
+ <span className="shrink-0 text-[10px] text-neutral-600">#{unit.uid}</span>
304
+ {unit.error !== undefined ? (
305
+ <button
306
+ type="button"
307
+ className="shrink-0 text-[10px] text-red-400 hover:text-red-300"
308
+ onClick={() => {
309
+ setErrorOpen((v) => !v);
310
+ }}
311
+ >
312
+ error {errorOpen ? '▾' : '▸'}
313
+ </button>
314
+ ) : null}
315
+ <span className="ml-auto flex shrink-0 gap-1">
316
+ {actions.map((action) => (
317
+ <ActionButton
318
+ key={action}
319
+ danger
320
+ disabled={trigger.busy !== null}
321
+ onClick={() => void trigger.run(action, scopePath, unit.token)}
322
+ >
323
+ {trigger.busy === triggerKey(action, scopePath, unit.token) ? '…' : action}
324
+ </ActionButton>
325
+ ))}
326
+ </span>
327
+ </div>
328
+ {errorOpen && unit.error !== undefined ? (
329
+ <pre className="mt-1 overflow-auto rounded bg-red-950/40 p-1.5 font-mono text-[10px] break-words whitespace-pre-wrap text-red-300">
330
+ {unit.error}
331
+ </pre>
332
+ ) : null}
333
+ </div>
334
+ );
335
+ }
336
+
337
+ const LEDGER_KIND_TONES: Record<LedgerEntryInfo['kind'], 'neutral' | 'sky' | 'violet'> = {
338
+ disposer: 'neutral',
339
+ effect: 'sky',
340
+ ledger: 'violet',
341
+ };
342
+
343
+ function LedgerSection({ entries }: { entries: readonly LedgerEntryInfo[] }) {
344
+ const [open, setOpen] = useState(false);
345
+ if (entries.length === 0) return null;
346
+ return (
347
+ <div className="mt-1">
348
+ <button
349
+ type="button"
350
+ className="text-[10px] text-neutral-500 select-none hover:text-neutral-300"
351
+ onClick={() => {
352
+ setOpen((v) => !v);
353
+ }}
354
+ >
355
+ {open ? '▾' : '▸'} ledger ({entries.length})
356
+ </button>
357
+ {open ? (
358
+ <div className="mt-1 border-l border-neutral-800/60 pl-2">
359
+ {entries.map((entry, i) => (
360
+ <LedgerEntryRow key={i} entry={entry} />
361
+ ))}
362
+ </div>
363
+ ) : null}
364
+ </div>
365
+ );
366
+ }
367
+
368
+ function LedgerEntryRow({ entry }: { entry: LedgerEntryInfo }) {
369
+ return (
370
+ <div className="py-0.5">
371
+ <span className="font-mono text-[10px] text-neutral-400" title={entry.stack}>
372
+ {entry.label}
373
+ </span>
374
+ <span className="ml-1.5">
375
+ <Badge tone={LEDGER_KIND_TONES[entry.kind]}>{entry.kind}</Badge>
376
+ </span>
377
+ {entry.children !== undefined && entry.children.length > 0 ? (
378
+ <div className="ml-2 border-l border-neutral-800/60 pl-2">
379
+ {entry.children.map((child, i) => (
380
+ <LedgerEntryRow key={i} entry={child} />
381
+ ))}
382
+ </div>
383
+ ) : null}
384
+ </div>
385
+ );
386
+ }
387
+
388
+ // ---------------------------------------------------------------------------
389
+ // Graph panel — the dependency DAG; rendering lives in di/DiGraphPanel.tsx
390
+ // ---------------------------------------------------------------------------
391
+
392
+ function GraphPanel() {
393
+ const query = useDiQuery('graph', (klient) => klient.core(IDebugGraphService).graph());
394
+ const gate = panelGate(query);
395
+ if (gate !== null) return gate;
396
+ return <DiGraphPanel graph={query.data as DebugGraph} />;
397
+ }
398
+
399
+ // ---------------------------------------------------------------------------
400
+ // Events panel — event subscriptions; rendering lives in di/DiEventsPanel.tsx
401
+ // ---------------------------------------------------------------------------
402
+
403
+ function EventsPanel() {
404
+ const query = useDiQuery('events', (klient) =>
405
+ klient.core(IDebugEventsService).subscriptions(),
406
+ );
407
+ const gate = panelGate(query);
408
+ if (gate !== null) return gate;
409
+ return <DiEventsPanel data={query.data as DebugEventSubscriptions} />;
410
+ }
411
+
412
+ // ---------------------------------------------------------------------------
413
+ // Cascade panel — the cross-scope cascade history rings, newest first
414
+ // ---------------------------------------------------------------------------
415
+
416
+ function CascadePanel() {
417
+ const query = useDiQuery('cascade', (klient) => klient.core(IDebugCascadeService).history());
418
+ const gate = panelGate(query);
419
+ if (gate !== null) return gate;
420
+ // seq is a per-engine ring sequence; cross-scope ordering is approximate.
421
+ const entries = (query.data as DebugCascadeEntry[]).toReversed();
422
+ if (entries.length === 0) {
423
+ return <div className="text-[11px] text-neutral-600 italic">no cascade transactions yet</div>;
424
+ }
425
+ return (
426
+ <div>
427
+ {entries.map((entry, i) => (
428
+ <CascadeEntryCard key={`${entry.scopePath}:${entry.seq}:${i}`} entry={entry} />
429
+ ))}
430
+ </div>
431
+ );
432
+ }
433
+
434
+ function CascadeEntryCard({ entry }: { entry: DebugCascadeEntry }) {
435
+ const [open, setOpen] = useState(false);
436
+ const expandable =
437
+ entry.failed.length > 0 || entry.affected.length > 0 || entry.tornDown.length > 0;
438
+ return (
439
+ <div className="mb-2 rounded border border-neutral-800 bg-neutral-900/60">
440
+ <div
441
+ className={`flex items-center gap-2 px-2 py-1.5 ${expandable ? 'cursor-pointer select-none' : ''}`}
442
+ onClick={
443
+ expandable
444
+ ? () => {
445
+ setOpen((v) => !v);
446
+ }
447
+ : undefined
448
+ }
449
+ >
450
+ <span className="shrink-0 font-mono text-[11px] text-neutral-200">#{entry.seq}</span>
451
+ <span className="shrink-0 font-mono text-[10px] text-neutral-500" title={entry.scopePath}>
452
+ {truncate(entry.scopePath, 40)}
453
+ </span>
454
+ {entry.abortWaited ? <Badge tone="amber">abort waited</Badge> : null}
455
+ {entry.abortTimedOut ? <Badge tone="red">abort timed out</Badge> : null}
456
+ {entry.failed.length > 0 ? <Badge tone="red">{entry.failed.length} failed</Badge> : null}
457
+ <span className="min-w-0 flex-1 truncate text-[11px] text-neutral-400" title={entry.reason}>
458
+ {entry.reason}
459
+ </span>
460
+ <span className="shrink-0 text-[10px] text-neutral-600">{entry.durationMs}ms</span>
461
+ {expandable ? (
462
+ <span className="shrink-0 text-[10px] text-neutral-600">{open ? '▾' : '▸'}</span>
463
+ ) : null}
464
+ </div>
465
+ <div className="border-t border-neutral-800/60 px-2 py-1.5">
466
+ <div className="flex flex-wrap gap-1">
467
+ {entry.changes.map((change, i) => (
468
+ <span
469
+ key={i}
470
+ className="rounded bg-neutral-800 px-1.5 py-0.5 font-mono text-[10px] text-neutral-300"
471
+ title={change.token}
472
+ >
473
+ {change.action} {truncate(change.token, 48)}
474
+ </span>
475
+ ))}
476
+ </div>
477
+ <div className="mt-1 text-[10px] text-neutral-500">
478
+ affected {entry.affected.length} · torn down {entry.tornDown.length} · rebuilt{' '}
479
+ {entry.rebuilt.length} · failed {entry.failed.length}
480
+ </div>
481
+ {open ? (
482
+ <div className="mt-1.5 space-y-1">
483
+ {entry.affected.length > 0 ? (
484
+ <TokenList label="affected" tokens={entry.affected} />
485
+ ) : null}
486
+ {entry.tornDown.length > 0 ? (
487
+ <TokenList label="torn down" tokens={entry.tornDown} />
488
+ ) : null}
489
+ {entry.failed.length > 0 ? (
490
+ <TokenList label="failed" tokens={entry.failed} danger />
491
+ ) : null}
492
+ </div>
493
+ ) : null}
494
+ </div>
495
+ </div>
496
+ );
497
+ }
498
+
499
+ function TokenList({
500
+ label,
501
+ tokens,
502
+ danger,
503
+ }: {
504
+ label: string;
505
+ tokens: readonly string[];
506
+ danger?: boolean;
507
+ }) {
508
+ return (
509
+ <div>
510
+ <div className="mb-0.5 text-[10px] font-semibold uppercase tracking-wider text-neutral-600">
511
+ {label}
512
+ </div>
513
+ <div className="flex flex-wrap gap-1">
514
+ {tokens.map((token, i) => (
515
+ <span
516
+ key={i}
517
+ className={`rounded px-1.5 py-0.5 font-mono text-[10px] ${
518
+ danger === true ? 'bg-red-950/60 text-red-300' : 'bg-neutral-800 text-neutral-300'
519
+ }`}
520
+ title={token}
521
+ >
522
+ {truncate(token, 64)}
523
+ </span>
524
+ ))}
525
+ </div>
526
+ </div>
527
+ );
528
+ }
529
+
530
+ // ---------------------------------------------------------------------------
531
+ // Pending panel — the waiting area + sticky failures per scope
532
+ // ---------------------------------------------------------------------------
533
+
534
+ function PendingPanel() {
535
+ const query = useDiQuery('pending', (klient) => klient.core(IDebugCascadeService).pending());
536
+ const trigger = useDiTrigger();
537
+ const gate = panelGate(query);
538
+ if (gate !== null) return gate;
539
+ const groups = query.data as DebugPendingGroup[];
540
+ return (
541
+ <div>
542
+ {trigger.error !== null ? (
543
+ <div className="mb-2">
544
+ <ErrorLine error={trigger.error} />
545
+ </div>
546
+ ) : null}
547
+ {groups.length === 0 ? (
548
+ <div className="text-[11px] text-neutral-600 italic">no waiting or failed units</div>
549
+ ) : (
550
+ groups.map((group) => (
551
+ <div
552
+ key={group.scopePath}
553
+ className="mb-2 rounded-lg border border-neutral-800 bg-neutral-900/60"
554
+ >
555
+ <div className="border-b border-neutral-800/60 px-3 py-2">
556
+ <span className="font-mono text-[11px] text-neutral-200">{group.scopePath}</span>
557
+ </div>
558
+ <div className="px-3 py-2">
559
+ {group.waiting.length > 0 ? (
560
+ <div className="mb-2">
561
+ <div className="mb-1 text-[10px] font-semibold uppercase tracking-wider text-neutral-600">
562
+ waiting ({group.waiting.length})
563
+ </div>
564
+ {group.waiting.map((unit) => (
565
+ <div
566
+ key={unit.token}
567
+ className="mb-1 flex items-center gap-2 rounded border border-neutral-800/70 bg-neutral-950/40 px-2 py-1.5"
568
+ >
569
+ <span
570
+ className="min-w-0 truncate font-mono text-[11px] text-neutral-200"
571
+ title={unit.token}
572
+ >
573
+ {unit.token}
574
+ </span>
575
+ <span className="shrink-0 text-[10px] text-neutral-600">missing:</span>
576
+ <span className="flex min-w-0 flex-wrap gap-1">
577
+ {unit.missing.map((dep, i) => (
578
+ <span
579
+ key={i}
580
+ className="rounded bg-amber-950/60 px-1.5 py-0.5 font-mono text-[10px] text-amber-300"
581
+ title={dep}
582
+ >
583
+ {truncate(dep, 48)}
584
+ </span>
585
+ ))}
586
+ </span>
587
+ </div>
588
+ ))}
589
+ </div>
590
+ ) : null}
591
+ {group.failed.length > 0 ? (
592
+ <div>
593
+ <div className="mb-1 text-[10px] font-semibold uppercase tracking-wider text-neutral-600">
594
+ failed ({group.failed.length})
595
+ </div>
596
+ {group.failed.map((unit) => (
597
+ <div
598
+ key={unit.token}
599
+ className="mb-1 rounded border border-red-900/50 bg-red-950/20 px-2 py-1.5"
600
+ >
601
+ <div className="flex items-center gap-2">
602
+ <span
603
+ className="min-w-0 truncate font-mono text-[11px] text-neutral-200"
604
+ title={unit.token}
605
+ >
606
+ {unit.token}
607
+ </span>
608
+ <span className="ml-auto shrink-0">
609
+ <ActionButton
610
+ danger
611
+ disabled={trigger.busy !== null}
612
+ onClick={() => void trigger.run('update', group.scopePath, unit.token)}
613
+ >
614
+ {trigger.busy === triggerKey('update', group.scopePath, unit.token)
615
+ ? '…'
616
+ : 'retry update'}
617
+ </ActionButton>
618
+ </span>
619
+ </div>
620
+ {unit.error !== undefined ? (
621
+ <pre className="mt-1 overflow-auto rounded bg-red-950/40 p-1.5 font-mono text-[10px] break-words whitespace-pre-wrap text-red-300">
622
+ {unit.error}
623
+ </pre>
624
+ ) : null}
625
+ </div>
626
+ ))}
627
+ </div>
628
+ ) : null}
629
+ </div>
630
+ </div>
631
+ ))
632
+ )}
633
+ </div>
634
+ );
635
+ }
apps/kimi-inspect/src/components/FsSuggestView.tsx ADDED
@@ -0,0 +1,256 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { IWorkspaceService, type Workspace } from '@moonshot-ai/agent-core-v2/app/workspace/workspace';
2
+ import { useMutation, useQuery } from '@tanstack/react-query';
3
+ import { useEffect, useState } from 'react';
4
+
5
+ import { useConnection } from '../connection';
6
+ import { fetchFsSuggest, type FsSuggestResult } from '../fs/api';
7
+ import { Badge, ErrorLine } from '../ui';
8
+ import { WorkspaceDirBrowser } from './WorkspaceDirBrowser';
9
+
10
+ function parseGlobs(value: string): string[] | undefined {
11
+ const globs = value
12
+ .split(',')
13
+ .map((glob) => glob.trim())
14
+ .filter((glob) => glob.length > 0);
15
+ return globs.length === 0 ? undefined : globs;
16
+ }
17
+
18
+ function parseRoots(value: string): string[] {
19
+ return value
20
+ .split(/[\n,]/)
21
+ .map((root) => root.trim())
22
+ .filter((root) => root.length > 0);
23
+ }
24
+
25
+ export function FsSuggestView() {
26
+ const { klient, baseUrl } = useConnection();
27
+ const [workspace, setWorkspace] = useState<Workspace | null>(null);
28
+ const [rootsText, setRootsText] = useState('');
29
+ const [query, setQuery] = useState('');
30
+ const [limit, setLimit] = useState('50');
31
+ const [followGitignore, setFollowGitignore] = useState(true);
32
+ const [showHidden, setShowHidden] = useState(false);
33
+ const [includeGlobs, setIncludeGlobs] = useState('');
34
+ const [excludeGlobs, setExcludeGlobs] = useState('');
35
+
36
+ const workspaces = useQuery({
37
+ queryKey: ['workspaces', klient.baseUrl],
38
+ queryFn: () => klient.core(IWorkspaceService).list(),
39
+ });
40
+
41
+ const suggest = useMutation<FsSuggestResult, Error>({
42
+ mutationFn: async () => {
43
+ const roots = parseRoots(rootsText);
44
+ const parsedLimit = Number.parseInt(limit, 10);
45
+ const shared = {
46
+ baseUrl: klient.baseUrl,
47
+ token: klient.token,
48
+ query,
49
+ limit: Number.isFinite(parsedLimit) && parsedLimit > 0 ? parsedLimit : undefined,
50
+ followGitignore,
51
+ showHidden,
52
+ includeGlobs: parseGlobs(includeGlobs),
53
+ excludeGlobs: parseGlobs(excludeGlobs),
54
+ };
55
+ if (roots.length > 0) {
56
+ return fetchFsSuggest({ ...shared, roots });
57
+ }
58
+ if (workspace === null) throw new Error('select a workspace or enter roots first');
59
+ return fetchFsSuggest({ ...shared, roots: [workspace.root] });
60
+ },
61
+ });
62
+
63
+ useEffect(() => {
64
+ setWorkspace(null);
65
+ suggest.reset();
66
+ }, [baseUrl]);
67
+
68
+ const selectWorkspace = (next: Workspace) => {
69
+ setWorkspace(next);
70
+ suggest.reset();
71
+ };
72
+
73
+ return (
74
+ <div className="flex min-h-0 min-w-0 flex-1">
75
+ <aside className="flex w-72 shrink-0 flex-col border-r border-neutral-800">
76
+ <div className="border-b border-neutral-800 px-3 py-2">
77
+ <div className="text-[10px] font-semibold uppercase tracking-wider text-neutral-500">
78
+ Workspace
79
+ </div>
80
+ <div title={workspace?.root} className="truncate font-mono text-[11px] text-neutral-300">
81
+ {workspace === null ? <span className="text-neutral-600 italic">none selected</span> : `${workspace.name} — ${workspace.root}`}
82
+ </div>
83
+ {workspaces.isError ? <ErrorLine error={workspaces.error} /> : null}
84
+ </div>
85
+ <WorkspaceDirBrowser
86
+ klient={klient}
87
+ workspaces={workspaces.data}
88
+ onSelect={selectWorkspace}
89
+ />
90
+ </aside>
91
+ <main className="min-h-0 min-w-0 flex-1 overflow-y-auto p-4">
92
+ <div className="mx-auto max-w-6xl space-y-4">
93
+ <div>
94
+ <h1 className="text-sm font-semibold text-neutral-200">Filesystem Suggest</h1>
95
+ <p className="mt-1 text-[11px] text-neutral-500">
96
+ Query file and directory completion candidates via the workspace-independent
97
+ fs:suggest API — the selected workspace supplies its root, or enter arbitrary
98
+ roots to override it.
99
+ </p>
100
+ </div>
101
+ <form
102
+ className="grid gap-3 rounded border border-neutral-800 bg-neutral-900/30 p-3 md:grid-cols-2"
103
+ onSubmit={(event) => {
104
+ event.preventDefault();
105
+ suggest.mutate();
106
+ }}
107
+ >
108
+ <label className="md:col-span-2">
109
+ <span className="mb-1 block text-[10px] font-semibold uppercase tracking-wider text-neutral-500">
110
+ Roots (absolute paths, one per line or comma-separated; overrides the workspace selection)
111
+ </span>
112
+ <textarea
113
+ className="h-16 w-full resize-y rounded border border-neutral-700 bg-neutral-950 px-2 py-1.5 font-mono text-[12px] text-neutral-100 outline-none focus:border-sky-600"
114
+ value={rootsText}
115
+ onChange={(event) => setRootsText(event.target.value)}
116
+ placeholder="/abs/primary-root&#10;/abs/additional-root"
117
+ />
118
+ </label>
119
+ <label className="md:col-span-2">
120
+ <span className="mb-1 block text-[10px] font-semibold uppercase tracking-wider text-neutral-500">
121
+ Query
122
+ </span>
123
+ <input
124
+ autoFocus
125
+ className="w-full rounded border border-neutral-700 bg-neutral-950 px-2 py-1.5 font-mono text-[12px] text-neutral-100 outline-none focus:border-sky-600"
126
+ value={query}
127
+ onChange={(event) => setQuery(event.target.value)}
128
+ placeholder="apps/de or README"
129
+ />
130
+ </label>
131
+ <label>
132
+ <span className="mb-1 block text-[10px] font-semibold uppercase tracking-wider text-neutral-500">
133
+ Limit
134
+ </span>
135
+ <input
136
+ className="w-full rounded border border-neutral-700 bg-neutral-950 px-2 py-1.5 font-mono text-[12px] text-neutral-100 outline-none focus:border-sky-600"
137
+ inputMode="numeric"
138
+ value={limit}
139
+ onChange={(event) => setLimit(event.target.value)}
140
+ />
141
+ </label>
142
+ <div className="flex items-end gap-4 pb-1 text-[11px] text-neutral-300">
143
+ <label className="flex items-center gap-1.5">
144
+ <input
145
+ type="checkbox"
146
+ checked={followGitignore}
147
+ onChange={(event) => setFollowGitignore(event.target.checked)}
148
+ />
149
+ follow gitignore
150
+ </label>
151
+ <label className="flex items-center gap-1.5">
152
+ <input
153
+ type="checkbox"
154
+ checked={showHidden}
155
+ onChange={(event) => setShowHidden(event.target.checked)}
156
+ />
157
+ show hidden
158
+ </label>
159
+ </div>
160
+ <label>
161
+ <span className="mb-1 block text-[10px] font-semibold uppercase tracking-wider text-neutral-500">
162
+ Include globs
163
+ </span>
164
+ <input
165
+ className="w-full rounded border border-neutral-700 bg-neutral-950 px-2 py-1.5 font-mono text-[11px] text-neutral-100 outline-none focus:border-sky-600"
166
+ value={includeGlobs}
167
+ onChange={(event) => setIncludeGlobs(event.target.value)}
168
+ placeholder="**/*.ts, src/**"
169
+ />
170
+ </label>
171
+ <label>
172
+ <span className="mb-1 block text-[10px] font-semibold uppercase tracking-wider text-neutral-500">
173
+ Exclude globs
174
+ </span>
175
+ <input
176
+ className="w-full rounded border border-neutral-700 bg-neutral-950 px-2 py-1.5 font-mono text-[11px] text-neutral-100 outline-none focus:border-sky-600"
177
+ value={excludeGlobs}
178
+ onChange={(event) => setExcludeGlobs(event.target.value)}
179
+ placeholder="dist/**, node_modules/**"
180
+ />
181
+ </label>
182
+ <div className="flex items-end md:col-span-2">
183
+ <button
184
+ type="submit"
185
+ disabled={(workspace === null && parseRoots(rootsText).length === 0) || suggest.isPending}
186
+ className="rounded bg-sky-600 px-3 py-1.5 text-[12px] font-medium text-white hover:bg-sky-500 disabled:opacity-40"
187
+ >
188
+ {suggest.isPending ? 'loading…' : 'Suggest'}
189
+ </button>
190
+ {workspace === null && parseRoots(rootsText).length === 0 ? (
191
+ <span className="ml-3 text-[11px] text-neutral-600">select a workspace or enter roots first</span>
192
+ ) : null}
193
+ </div>
194
+ </form>
195
+ {suggest.isError ? <ErrorLine error={suggest.error} /> : null}
196
+ {suggest.data === undefined && !suggest.isError ? (
197
+ <div className="rounded border border-dashed border-neutral-800 p-6 text-center text-[12px] text-neutral-600">
198
+ Submit a query to inspect the complete response.
199
+ </div>
200
+ ) : null}
201
+ {suggest.data !== undefined ? <SuggestResult result={suggest.data} /> : null}
202
+ </div>
203
+ </main>
204
+ </div>
205
+ );
206
+ }
207
+
208
+ function SuggestResult({ result }: { readonly result: FsSuggestResult }) {
209
+ return (
210
+ <div className="space-y-3">
211
+ <div className="flex items-center gap-2 text-[11px] text-neutral-400">
212
+ <span>{result.items.length} items</span>
213
+ <Badge tone={result.truncated ? 'amber' : 'green'}>
214
+ {result.truncated ? 'truncated' : 'complete'}
215
+ </Badge>
216
+ </div>
217
+ <section className="overflow-x-auto rounded border border-neutral-800">
218
+ <table className="w-full min-w-[680px] text-left text-[11px]">
219
+ <thead className="border-b border-neutral-800 bg-neutral-900/50 text-neutral-500">
220
+ <tr>
221
+ <th className="px-2 py-1.5">path</th>
222
+ <th className="px-2 py-1.5">name</th>
223
+ <th className="px-2 py-1.5">kind</th>
224
+ <th className="px-2 py-1.5">score</th>
225
+ <th className="px-2 py-1.5">match positions</th>
226
+ </tr>
227
+ </thead>
228
+ <tbody>
229
+ {result.items.map((item) => (
230
+ <tr key={item.path} className="border-b border-neutral-900 last:border-0">
231
+ <td className="px-2 py-1.5 font-mono text-neutral-200">{item.path}</td>
232
+ <td className="px-2 py-1.5 font-mono text-neutral-400">{item.name}</td>
233
+ <td className="px-2 py-1.5 text-neutral-400">{item.kind}</td>
234
+ <td className="px-2 py-1.5 font-mono text-neutral-400">{item.score}</td>
235
+ <td className="px-2 py-1.5 font-mono text-neutral-400">
236
+ {item.matchPositions.join(', ') || '—'}
237
+ </td>
238
+ </tr>
239
+ ))}
240
+ </tbody>
241
+ </table>
242
+ {result.items.length === 0 ? (
243
+ <div className="p-4 text-center text-[11px] text-neutral-600">no matching items</div>
244
+ ) : null}
245
+ </section>
246
+ <details className="rounded border border-neutral-800 bg-neutral-950/50">
247
+ <summary className="cursor-pointer px-3 py-2 text-[11px] font-semibold uppercase tracking-wider text-neutral-500">
248
+ Full JSON response
249
+ </summary>
250
+ <pre className="max-h-[420px] overflow-auto border-t border-neutral-800 p-3 text-[11px] leading-relaxed text-neutral-300">
251
+ {JSON.stringify(result, null, 2)}
252
+ </pre>
253
+ </details>
254
+ </div>
255
+ );
256
+ }
apps/kimi-inspect/src/components/Inspector.tsx ADDED
@@ -0,0 +1,308 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Agent inspector — the `Agent` tab of the chat view's right dock
3
+ * (`RightPanel`; it used to be a standalone 420px column on the far right).
4
+ * Hosts the agent switcher, the Plan lookup card, and the agent Service
5
+ * panels (`ScopePanels`). The session scope lives in the `SessionPane`
6
+ * column next to the sidebar (pending interactions, session Services,
7
+ * session State); the app-scope (server-level) Services live in their own
8
+ * rail view (`AppServicesView`), not here.
9
+ *
10
+ * Everything here is fetch-on-demand (Load / Refresh buttons): the v2 event
11
+ * socket (`/api/v2/ws`) that used to push core/session/agent event streams
12
+ * — live panel refetches, the pending-interaction push, the merged event
13
+ * log — was removed server-side, so there is no live push to render.
14
+ */
15
+
16
+ import { ISessionMetadata } from '@moonshot-ai/agent-core-v2/session/sessionMetadata/sessionMetadata';
17
+ import { useQuery } from '@tanstack/react-query';
18
+ import { useEffect, useMemo, useState } from 'react';
19
+
20
+ import { serviceByName } from '../channel';
21
+ import { useConnection } from '../connection';
22
+ import { type AnyService } from '../panels';
23
+ import { fetchAgentRuntimeBinding } from '../snapshots/api';
24
+ import { fetchFullHistory } from '../transcript/api';
25
+ import { projectPlans, type PlanInfo } from '../transcript/plan';
26
+ import { ActionButton, Badge, ErrorLine } from '../ui';
27
+ import { ScopePanels } from './ServicePanels';
28
+
29
+ export function Inspector({
30
+ sessionId,
31
+ agentId,
32
+ onAgentChange,
33
+ ready,
34
+ }: {
35
+ sessionId: string | null;
36
+ agentId: string;
37
+ onAgentChange: (agentId: string) => void;
38
+ ready: boolean;
39
+ }) {
40
+ const { klient } = useConnection();
41
+
42
+ const meta = useQuery({
43
+ queryKey: ['sessionMeta', sessionId],
44
+ queryFn: () =>
45
+ klient
46
+ .session(sessionId as string)
47
+ .service(ISessionMetadata)
48
+ .read(),
49
+ enabled: sessionId !== null && ready,
50
+ });
51
+
52
+ const agentIds = useMemo(() => {
53
+ const ids = Object.keys(meta.data?.agents ?? {});
54
+ if (ids.length === 0) return ['main'];
55
+ return ['main', ...ids.filter((id) => id !== 'main')].filter(
56
+ (id, i, all) => all.indexOf(id) === i,
57
+ );
58
+ }, [meta.data]);
59
+
60
+ // Keep the selected agent valid as the registry changes.
61
+ const effectiveAgent = agentIds.includes(agentId) ? agentId : agentIds[0]!;
62
+ const runtimeBinding = useQuery({
63
+ queryKey: ['agent-runtime-binding', klient.baseUrl, sessionId, effectiveAgent],
64
+ queryFn: () => fetchAgentRuntimeBinding(klient, sessionId as string, effectiveAgent),
65
+ enabled: sessionId !== null && ready,
66
+ refetchInterval: 1_000,
67
+ });
68
+ useEffect(() => {
69
+ if (effectiveAgent !== agentId) onAgentChange(effectiveAgent);
70
+ }, [effectiveAgent, agentId, onAgentChange]);
71
+
72
+ // Subagents stay in the metadata registry even when their scope is not
73
+ // materialized in this process (created before a restart, or disposed on
74
+ // session close), so the switcher lists entries that cannot be called.
75
+ // Mark one as "not loaded" when an agent-scope call comes back with
76
+ // `agent.not_found` (message names the agent).
77
+ const [stoppedAgents, setStoppedAgents] = useState<ReadonlySet<string>>(new Set());
78
+ useEffect(() => setStoppedAgents(new Set()), [sessionId]);
79
+ const noteAgentError = (agent: string, error: unknown) => {
80
+ if (error instanceof Error && error.message.includes('not found in session')) {
81
+ setStoppedAgents((prev) => (prev.has(agent) ? prev : new Set(prev).add(agent)));
82
+ }
83
+ };
84
+
85
+ // Resolve a Service proxy by channel name, 1:1 with the channel descriptor
86
+ // from `/api/v1/debug/channels`. Returns null when the scope needs a
87
+ // session that isn't selected/ready.
88
+ const proxyFor = useMemo(() => {
89
+ return (name: string): AnyService | null => {
90
+ return (
91
+ serviceByName<AnyService>(klient, name, {
92
+ scope: 'agent',
93
+ sessionId: sessionId !== null && ready ? sessionId : undefined,
94
+ agentId: effectiveAgent,
95
+ }) ?? null
96
+ );
97
+ };
98
+ }, [klient, sessionId, effectiveAgent, ready]);
99
+
100
+ const sessionBlocked = sessionId === null || !ready;
101
+
102
+ return (
103
+ <div className="flex h-full flex-col">
104
+ {/* Agent switcher */}
105
+ {sessionId !== null ? (
106
+ <div className="border-b border-neutral-800 px-3 py-2">
107
+ <label className="mb-1 block text-[10px] font-semibold uppercase tracking-wider text-neutral-500">
108
+ Active agent
109
+ </label>
110
+ <select
111
+ className="w-full rounded border border-neutral-700 bg-neutral-950 px-2 py-1.5 text-[12px] text-neutral-100 outline-none focus:border-sky-600"
112
+ value={effectiveAgent}
113
+ onChange={(e) => onAgentChange(e.target.value)}
114
+ >
115
+ {agentIds.map((id) => (
116
+ <option key={id} value={id}>
117
+ {stoppedAgents.has(id) ? `${id} (not loaded)` : id}
118
+ </option>
119
+ ))}
120
+ </select>
121
+ {stoppedAgents.has(effectiveAgent) ? (
122
+ <div className="mt-1 text-[10px] text-neutral-600">
123
+ this agent is not materialized in the running server (e.g. created before a restart) —
124
+ calls will fail; its persisted records remain on disk
125
+ </div>
126
+ ) : null}
127
+ {meta.isError ? (
128
+ <div className="mt-1">
129
+ <ErrorLine error={meta.error} />
130
+ </div>
131
+ ) : null}
132
+ </div>
133
+ ) : null}
134
+
135
+ <div className="flex-1 overflow-y-auto p-3">
136
+ {sessionBlocked ? (
137
+ <div className="text-[12px] text-neutral-600">
138
+ {sessionId === null ? 'No session selected.' : 'Loading session…'}
139
+ </div>
140
+ ) : (
141
+ <>
142
+ <div className="mb-3 rounded border border-neutral-800 bg-neutral-950/40 p-2 text-[11px]">
143
+ <div className="mb-1 flex items-center gap-2 font-semibold uppercase tracking-wider text-neutral-500">
144
+ Runtime binding
145
+ {runtimeBinding.data !== undefined ? (
146
+ <Badge tone={runtimeBinding.data.available ? 'green' : 'red'}>
147
+ {runtimeBinding.data.available ? 'available' : 'unavailable'}
148
+ </Badge>
149
+ ) : null}
150
+ </div>
151
+ <div className="grid grid-cols-[80px_minmax(0,1fr)] gap-1 font-mono">
152
+ <span className="text-neutral-600">workspace</span>
153
+ <span className="break-all text-neutral-300">{runtimeBinding.data?.binding.workspaceId ?? 'loading…'}</span>
154
+ <span className="text-neutral-600">runtime</span>
155
+ <span className="break-all text-neutral-300">{runtimeBinding.data?.binding.runtimeId ?? 'loading…'}</span>
156
+ <span className="text-neutral-600">generation</span>
157
+ <span className="break-all text-neutral-300">{runtimeBinding.data?.runtime?.generation ?? 'unavailable'}</span>
158
+ <span className="text-neutral-600">status</span>
159
+ <span className="text-neutral-300">{runtimeBinding.data?.runtime?.status ?? 'unavailable'}</span>
160
+ <span className="text-neutral-600">capabilities</span>
161
+ <span className="text-neutral-300">{runtimeBinding.data?.runtime?.capabilities.join(', ') ?? 'none'}</span>
162
+ </div>
163
+ {runtimeBinding.isError ? <ErrorLine error={runtimeBinding.error} /> : null}
164
+ </div>
165
+ <PlanCard sessionId={sessionId} agentId={effectiveAgent} />
166
+ <ScopePanels
167
+ scope="agent"
168
+ proxyFor={proxyFor}
169
+ onError={(error) => noteAgentError(effectiveAgent, error)}
170
+ />
171
+ </>
172
+ )}
173
+ </div>
174
+ </div>
175
+ );
176
+ }
177
+
178
+ // ---------------------------------------------------------------------------
179
+ // Plan lookup — derived from the message stream (`GET /sessions/{id}/history`
180
+ // full read + client-side `projectPlans`): the reviewed plan of one
181
+ // ExitPlanMode tool call, found by tool_call_id (copy it from a tool frame in
182
+ // the chat view), or every plan of the agent. Read-only, fetched on demand
183
+ // like everything else here.
184
+ // ---------------------------------------------------------------------------
185
+
186
+ function PlanCard({ sessionId, agentId }: { sessionId: string; agentId: string }) {
187
+ const { baseUrl, config } = useConnection();
188
+ const [toolCallId, setToolCallId] = useState('');
189
+ const [result, setResult] = useState<readonly PlanInfo[] | null>(null);
190
+ const [error, setError] = useState<unknown>(null);
191
+ const [loading, setLoading] = useState(false);
192
+
193
+ // A plan belongs to one agent's timeline — stale results from another
194
+ // session/agent are misleading, so reset on switch.
195
+ useEffect(() => {
196
+ setResult(null);
197
+ setError(null);
198
+ }, [sessionId, agentId]);
199
+
200
+ const query = async () => {
201
+ setLoading(true);
202
+ try {
203
+ setError(null);
204
+ const token = config.token.trim();
205
+ const messages = await fetchFullHistory({
206
+ baseUrl,
207
+ token: token === '' ? undefined : token,
208
+ sessionId,
209
+ agentId,
210
+ });
211
+ const id = toolCallId.trim();
212
+ setResult(projectPlans(messages, id === '' ? undefined : id));
213
+ } catch (error) {
214
+ setResult(null);
215
+ setError(error);
216
+ } finally {
217
+ setLoading(false);
218
+ }
219
+ };
220
+
221
+ return (
222
+ <div className="mb-3 rounded-lg border border-neutral-800 bg-neutral-950/40">
223
+ <div className="flex items-center justify-between border-b border-neutral-800 px-3 py-2">
224
+ <span className="text-[12px] font-medium text-neutral-200">Plan lookup</span>
225
+ <Badge tone="sky">{agentId}</Badge>
226
+ </div>
227
+ <div className="px-3 py-2">
228
+ <div className="flex gap-1.5">
229
+ <input
230
+ className="min-w-0 flex-1 rounded border border-neutral-700 bg-neutral-950 px-2 py-1 font-mono text-[11px] text-neutral-100 outline-none focus:border-sky-600"
231
+ placeholder="tool_call_id (empty = all plans)"
232
+ value={toolCallId}
233
+ onChange={(e) => setToolCallId(e.target.value)}
234
+ onKeyDown={(e) => {
235
+ if (e.key === 'Enter') void query();
236
+ }}
237
+ />
238
+ <ActionButton disabled={loading} onClick={() => void query()}>
239
+ {loading ? 'Loading…' : 'Query'}
240
+ </ActionButton>
241
+ </div>
242
+ {error !== null ? (
243
+ <div className="mt-2">
244
+ <ErrorLine error={error} />
245
+ </div>
246
+ ) : null}
247
+ {result !== null ? (
248
+ result.length === 0 ? (
249
+ <div className="mt-2 text-[11px] text-neutral-600 italic">no plans on this agent</div>
250
+ ) : (
251
+ result.map((entry) => <PlanEntryView key={entry.toolCallId} entry={entry} />)
252
+ )
253
+ ) : null}
254
+ </div>
255
+ </div>
256
+ );
257
+ }
258
+
259
+ function PlanEntryView({ entry }: { entry: PlanInfo }) {
260
+ const review = entry.review;
261
+ return (
262
+ <div className="mt-2">
263
+ <div className="mb-1.5 flex flex-wrap items-center gap-1.5">
264
+ <span className="font-mono text-[10px] text-neutral-400 select-all">
265
+ {entry.toolCallId}
266
+ </span>
267
+ <Badge tone="neutral">{entry.source}</Badge>
268
+ {review !== undefined ? (
269
+ <Badge
270
+ tone={
271
+ review.state === 'approved' ? 'green' : review.state === 'pending' ? 'amber' : 'red'
272
+ }
273
+ >
274
+ {review.state}
275
+ </Badge>
276
+ ) : null}
277
+ <span className="font-mono text-[10px] text-neutral-500">{entry.turnId}</span>
278
+ </div>
279
+ {entry.path !== undefined ? (
280
+ <div className="mb-1 break-all font-mono text-[10px] text-neutral-500">{entry.path}</div>
281
+ ) : null}
282
+ {review?.selectedOption !== undefined ? (
283
+ <div className="mb-1 text-[11px] text-neutral-400">
284
+ <span className="text-neutral-600">selected: </span>
285
+ {review.selectedOption}
286
+ </div>
287
+ ) : null}
288
+ {review?.feedback !== undefined ? (
289
+ <div className="mb-1 text-[11px] text-neutral-400">
290
+ <span className="text-neutral-600">feedback: </span>
291
+ {review.feedback}
292
+ </div>
293
+ ) : null}
294
+ {entry.options !== undefined ? (
295
+ <div className="mb-1 flex flex-wrap gap-1.5">
296
+ {entry.options.map((option) => (
297
+ <Badge key={option.label} tone="violet">
298
+ {option.label}
299
+ </Badge>
300
+ ))}
301
+ </div>
302
+ ) : null}
303
+ <pre className="max-h-72 overflow-auto rounded border border-neutral-800 bg-neutral-950 p-2 text-[11px] whitespace-pre-wrap text-neutral-300">
304
+ {entry.plan}
305
+ </pre>
306
+ </div>
307
+ );
308
+ }
apps/kimi-inspect/src/components/InteractionsCard.tsx ADDED
@@ -0,0 +1,209 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Pending interactions card (approvals / questions) of one session — fetched
3
+ * on demand over the public REST surface (`/api/v1/sessions/{id}/approvals`
4
+ * and `.../questions`): the engine's interaction kernel is a process-global
5
+ * singleton with no debug channel, and the session `interactions` push
6
+ * stream went away with `/api/v2/ws`, so the card refreshes only when Load
7
+ * is clicked.
8
+ */
9
+
10
+ import { useState } from 'react';
11
+
12
+ import { useConnection } from '../connection';
13
+ import {
14
+ answerQuestion,
15
+ decideApproval,
16
+ dismissQuestion,
17
+ listPendingApprovals,
18
+ listPendingQuestions,
19
+ type QuestionAnswerWire,
20
+ type QuestionWire,
21
+ } from '../interactions/api';
22
+ import { ActionButton, Badge, ErrorLine, JsonView } from '../ui';
23
+
24
+ interface PendingInteraction {
25
+ readonly id: string;
26
+ /** Known kinds: 'approval' | 'question'. */
27
+ readonly kind: string;
28
+ readonly payload: Record<string, unknown>;
29
+ }
30
+
31
+ export function InteractionsCard({ sessionId }: { sessionId: string }) {
32
+ const { config, baseUrl } = useConnection();
33
+ const [pending, setPending] = useState<readonly PendingInteraction[]>([]);
34
+ const [error, setError] = useState<unknown>(null);
35
+ const api = { baseUrl, token: config.token, sessionId };
36
+
37
+ const reload = async () => {
38
+ try {
39
+ setError(null);
40
+ const [approvals, questions] = await Promise.all([
41
+ listPendingApprovals(api),
42
+ listPendingQuestions(api),
43
+ ]);
44
+ setPending([
45
+ ...approvals.map((p) => ({
46
+ id: p.approval_id,
47
+ kind: 'approval',
48
+ payload: {
49
+ toolName: p.tool_name,
50
+ action: p.action,
51
+ display: p.tool_input_display,
52
+ },
53
+ })),
54
+ ...questions.map((p) => ({
55
+ id: p.question_id,
56
+ kind: 'question',
57
+ payload: p as unknown as Record<string, unknown>,
58
+ })),
59
+ ]);
60
+ } catch (error) {
61
+ setError(error);
62
+ }
63
+ };
64
+
65
+ const decide = async (id: string, decision: 'approved' | 'rejected') => {
66
+ try {
67
+ await decideApproval(api, id, decision);
68
+ await reload();
69
+ } catch (error) {
70
+ setError(error);
71
+ }
72
+ };
73
+ const answer = async (id: string, answers: Readonly<Record<string, QuestionAnswerWire>>) => {
74
+ try {
75
+ await answerQuestion(api, id, answers, 'click');
76
+ await reload();
77
+ } catch (error) {
78
+ setError(error);
79
+ }
80
+ };
81
+ const dismiss = async (id: string) => {
82
+ try {
83
+ await dismissQuestion(api, id);
84
+ await reload();
85
+ } catch (error) {
86
+ setError(error);
87
+ }
88
+ };
89
+
90
+ return (
91
+ <div className="mb-3 rounded-lg border border-amber-900/50 bg-amber-950/20">
92
+ <div className="flex items-center justify-between border-b border-amber-900/40 px-3 py-2">
93
+ <span className="text-[12px] font-medium text-amber-200">
94
+ Pending interactions {pending.length > 0 ? `(${pending.length})` : ''}
95
+ </span>
96
+ <ActionButton onClick={() => void reload()}>Load</ActionButton>
97
+ </div>
98
+ <div className="px-3 py-2">
99
+ {error !== null ? (
100
+ <div className="mb-2">
101
+ <ErrorLine error={error} />
102
+ </div>
103
+ ) : null}
104
+ {pending.length === 0 ? (
105
+ <div className="text-[11px] text-neutral-600 italic">
106
+ nothing pending (click Load to check)
107
+ </div>
108
+ ) : (
109
+ pending.map((item) => (
110
+ <div
111
+ key={item.id}
112
+ className="mb-2 rounded border border-neutral-800 bg-neutral-950/60 p-2"
113
+ >
114
+ <div className="mb-1 flex items-center gap-2">
115
+ <Badge tone="amber">{item.kind}</Badge>
116
+ <span className="font-mono text-[10px] text-neutral-500">{item.id}</span>
117
+ </div>
118
+ {item.kind === 'approval' ? (
119
+ <>
120
+ <div className="mb-1.5 text-[11px] text-neutral-300">
121
+ <span className="text-neutral-500">tool </span>
122
+ {payloadField(item.payload, 'toolName', '?')}
123
+ <span className="text-neutral-500"> · </span>
124
+ {payloadField(item.payload, 'action', '')}
125
+ </div>
126
+ <JsonView data={item.payload['display'] ?? item.payload} />
127
+ <div className="mt-2 flex gap-1.5">
128
+ <ActionButton onClick={() => void decide(item.id, 'approved')}>
129
+ Approve
130
+ </ActionButton>
131
+ <ActionButton danger onClick={() => void decide(item.id, 'rejected')}>
132
+ Reject
133
+ </ActionButton>
134
+ </div>
135
+ </>
136
+ ) : item.kind === 'question' ? (
137
+ <QuestionView
138
+ wire={item.payload as unknown as QuestionWire}
139
+ onAnswer={(answers) => void answer(item.id, answers)}
140
+ onDismiss={() => void dismiss(item.id)}
141
+ />
142
+ ) : (
143
+ <JsonView data={item.payload} />
144
+ )}
145
+ </div>
146
+ ))
147
+ )}
148
+ </div>
149
+ </div>
150
+ );
151
+ }
152
+
153
+ function QuestionView({
154
+ wire,
155
+ onAnswer,
156
+ onDismiss,
157
+ }: {
158
+ wire: QuestionWire;
159
+ onAnswer: (answers: Readonly<Record<string, QuestionAnswerWire>>) => void;
160
+ onDismiss: () => void;
161
+ }) {
162
+ return (
163
+ <>
164
+ {wire.questions.map((q) => (
165
+ <div key={q.id} className="mb-1.5">
166
+ <div className="mb-1 text-[11px] text-neutral-300">{q.question}</div>
167
+ <div className="flex flex-wrap gap-1.5">
168
+ {q.options.map((opt) => (
169
+ <ActionButton
170
+ key={opt.id}
171
+ onClick={() => onAnswer({ [q.id]: { kind: 'single', option_id: opt.id } })}
172
+ >
173
+ {opt.label}
174
+ </ActionButton>
175
+ ))}
176
+ {q.allow_other === false ? null : (
177
+ <ActionButton
178
+ onClick={() => {
179
+ const raw = window.prompt(q.question);
180
+ if (raw !== null) onAnswer({ [q.id]: { kind: 'other', text: raw } });
181
+ }}
182
+ >
183
+ Other…
184
+ </ActionButton>
185
+ )}
186
+ </div>
187
+ </div>
188
+ ))}
189
+ {wire.questions.length === 0 ? <JsonView data={wire} /> : null}
190
+ <div className="mt-1.5">
191
+ <ActionButton danger onClick={onDismiss}>
192
+ Dismiss
193
+ </ActionButton>
194
+ </div>
195
+ </>
196
+ );
197
+ }
198
+
199
+ /**
200
+ * Render a wire payload field as display text: strings pass through,
201
+ * numbers/booleans are stringified, anything else (or missing) falls back —
202
+ * never "[object Object]".
203
+ */
204
+ function payloadField(payload: Record<string, unknown>, key: string, fallback: string): string {
205
+ const value = payload[key];
206
+ if (typeof value === 'string') return value;
207
+ if (typeof value === 'number' || typeof value === 'boolean') return String(value);
208
+ return fallback;
209
+ }
apps/kimi-inspect/src/components/ModelCatalogView.tsx ADDED
@@ -0,0 +1,413 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Model Catalog view — providers with their models and the default marker:
3
+ *
4
+ * left: every configured model (provider-grouped), its highlight synced
5
+ * both ways with the center column — scrolling the center moves the
6
+ * highlight (scrollspy), clicking an entry jumps the center to that
7
+ * model;
8
+ * center: one section per model with ping and session creation actions.
9
+ *
10
+ * All data goes through the channel layer — `IModelCatalog` +
11
+ * `IModelService` for the list — over the `/api/v1/debug` RPC surface.
12
+ * There is no live event push; the queries refresh on a slow poll.
13
+ */
14
+
15
+ import { IAgentProfileService } from '@moonshot-ai/agent-core-v2/agent/profile/profile';
16
+ import { ISessionManager } from '@moonshot-ai/agent-core-v2/app/sessionManager/sessionManager';
17
+ import type { TokenUsage } from '@moonshot-ai/agent-core-v2/human/llm/usage';
18
+ import {
19
+ IModelCatalog,
20
+ type ModelCatalogItem,
21
+ type ModelPingResult,
22
+ type ProviderCatalogItem,
23
+ } from '@moonshot-ai/agent-core-v2/llm-adapter/model/catalog';
24
+ import { IModelService } from '@moonshot-ai/agent-core-v2/llm-adapter/model/model';
25
+ import { useQuery, useQueryClient } from '@tanstack/react-query';
26
+ import { useEffect, useRef, useState } from 'react';
27
+
28
+ import { useConnection } from '../connection';
29
+ import { ActionButton, Badge, ErrorLine, errorMessage } from '../ui';
30
+
31
+ interface FlatEntry {
32
+ readonly item: ModelCatalogItem;
33
+ readonly provider?: ProviderCatalogItem;
34
+ }
35
+
36
+ export function ModelCatalogView({
37
+ onOpenSession,
38
+ }: {
39
+ readonly onOpenSession: (sessionId: string) => void;
40
+ }) {
41
+ const { klient } = useConnection();
42
+ const queryClient = useQueryClient();
43
+
44
+ const providers = useQuery({
45
+ queryKey: ['modelCatalog', 'providers'],
46
+ queryFn: () => klient.core(IModelCatalog).listProviders(),
47
+ refetchInterval: 15_000,
48
+ });
49
+ const models = useQuery({
50
+ queryKey: ['modelCatalog', 'models'],
51
+ queryFn: () => klient.core(IModelCatalog).listModels(),
52
+ refetchInterval: 15_000,
53
+ });
54
+ // Raw records carry the structured `providerId` grouping fallback.
55
+ const records = useQuery({
56
+ queryKey: ['modelCatalog', 'records'],
57
+ queryFn: () => klient.core(IModelService).list(),
58
+ refetchInterval: 15_000,
59
+ });
60
+
61
+ const providerList = providers.data ?? [];
62
+ const items = models.data ?? [];
63
+ const recordMap = records.data ?? {};
64
+
65
+ // Flatten the provider grouping into one ordered model list (listed
66
+ // providers first, then models whose group matches no listed provider).
67
+ const groupKeyOf = (item: ModelCatalogItem): string => {
68
+ const record = recordMap[item.model];
69
+ return record?.providerId ?? record?.provider ?? item.provider;
70
+ };
71
+ const byGroup = new Map<string, ModelCatalogItem[]>();
72
+ for (const item of items) {
73
+ const key = groupKeyOf(item);
74
+ const group = byGroup.get(key) ?? [];
75
+ group.push(item);
76
+ byGroup.set(key, group);
77
+ }
78
+ const listedIds = new Set(providerList.map((p) => p.id));
79
+ const extraGroups = [...byGroup.keys()].filter((key) => !listedIds.has(key));
80
+ const flatEntries: FlatEntry[] = [
81
+ ...providerList.flatMap((provider) =>
82
+ (byGroup.get(provider.id) ?? []).map((item) => ({ item, provider }) as const),
83
+ ),
84
+ ...extraGroups.flatMap((key) => (byGroup.get(key) ?? []).map((item) => ({ item }) as const)),
85
+ ];
86
+
87
+ // --- two-way sync between the left list and the center scroll ----------
88
+ const [activeId, setActiveId] = useState<string | null>(null);
89
+ const scrollRef = useRef<HTMLDivElement>(null);
90
+ const listRef = useRef<HTMLDivElement>(null);
91
+ const sectionRefs = useRef(new Map<string, HTMLElement>());
92
+ const itemRefs = useRef(new Map<string, HTMLElement>());
93
+
94
+ // Default the highlight to the first model once data arrives, and follow
95
+ // the list when the highlighted model disappears (config changed).
96
+ useEffect(() => {
97
+ if (flatEntries.length === 0) return;
98
+ if (activeId === null || !flatEntries.some((entry) => entry.item.model === activeId)) {
99
+ setActiveId(flatEntries[0]!.item.model);
100
+ }
101
+ }, [activeId, flatEntries]);
102
+
103
+ // Center scroll → left highlight (scrollspy). Computed synchronously — a
104
+ // rAF throttle would stall in background tabs and lag real scrolling; the
105
+ // walk over the entry list is trivial at this scale.
106
+ const handleCenterScroll = () => {
107
+ const container = scrollRef.current;
108
+ if (container === null) return;
109
+ const top = container.scrollTop + 12;
110
+ let current: string | null = null;
111
+ for (const entry of flatEntries) {
112
+ const el = sectionRefs.current.get(entry.item.model);
113
+ if (el === undefined) continue;
114
+ if (el.offsetTop <= top) current = entry.item.model;
115
+ else break;
116
+ }
117
+ if (current !== null) setActiveId((prev) => (prev === current ? prev : current));
118
+ };
119
+
120
+ // Left highlight → keep the highlighted entry visible inside the left list
121
+ // (manual scroll: scrollIntoView would also move the center column).
122
+ useEffect(() => {
123
+ if (activeId === null) return;
124
+ const list = listRef.current;
125
+ const el = itemRefs.current.get(activeId);
126
+ if (list === null || el === undefined) return;
127
+ const top = el.offsetTop;
128
+ const bottom = top + el.offsetHeight;
129
+ if (top < list.scrollTop) list.scrollTop = top;
130
+ else if (bottom > list.scrollTop + list.clientHeight) {
131
+ list.scrollTop = bottom - list.clientHeight;
132
+ }
133
+ }, [activeId]);
134
+
135
+ const jumpTo = (modelId: string) => {
136
+ setActiveId(modelId);
137
+ // 'instant', not 'smooth': smooth scrolling is frame-driven and stalls in
138
+ // occluded tabs, leaving the center column stranded mid-jump.
139
+ sectionRefs.current.get(modelId)?.scrollIntoView({ behavior: 'instant', block: 'start' });
140
+ };
141
+
142
+ const loading = providers.isLoading || models.isLoading || records.isLoading;
143
+ const error = providers.error ?? models.error ?? records.error;
144
+
145
+ return (
146
+ <div className="flex min-h-0 flex-1 flex-col">
147
+ <div className="flex items-center gap-2 border-b border-neutral-800 px-4 py-2">
148
+ <span className="text-[12px] font-semibold uppercase tracking-wider text-neutral-400">
149
+ Model Catalog
150
+ </span>
151
+ <span className="text-[11px] text-neutral-600">
152
+ {providerList.length} providers · {items.length} models
153
+ </span>
154
+ <div className="flex-1" />
155
+ <ActionButton onClick={() => queryClient.invalidateQueries({ queryKey: ['modelCatalog'] })}>
156
+ Refresh
157
+ </ActionButton>
158
+ </div>
159
+ {error !== null ? (
160
+ <div className="px-4 py-2">
161
+ <ErrorLine error={error} />
162
+ </div>
163
+ ) : null}
164
+ {loading ? <div className="px-4 py-2 text-[11px] text-neutral-600">loading…</div> : null}
165
+ {!loading && flatEntries.length === 0 ? (
166
+ <div className="px-4 py-2 text-[11px] text-neutral-600">
167
+ no providers or models configured
168
+ </div>
169
+ ) : null}
170
+ <div className="flex min-h-0 flex-1">
171
+ {/* left: the model list */}
172
+ <div
173
+ ref={listRef}
174
+ className="relative w-72 shrink-0 overflow-y-auto border-r border-neutral-800"
175
+ >
176
+ <LeftList entries={flatEntries} activeId={activeId} onJump={jumpTo} itemRefs={itemRefs} />
177
+ </div>
178
+ {/* center: one section per model */}
179
+ <div
180
+ ref={scrollRef}
181
+ className="relative min-w-0 flex-1 overflow-y-auto"
182
+ onScroll={handleCenterScroll}
183
+ >
184
+ {flatEntries.map((entry) => (
185
+ <ModelSection
186
+ key={entry.item.model}
187
+ entry={entry}
188
+ onOpenSession={onOpenSession}
189
+ registerRef={(el) => {
190
+ if (el === null) sectionRefs.current.delete(entry.item.model);
191
+ else sectionRefs.current.set(entry.item.model, el);
192
+ }}
193
+ />
194
+ ))}
195
+ </div>
196
+ </div>
197
+ </div>
198
+ );
199
+ }
200
+
201
+ // ---------------------------------------------------------------------------
202
+ // left column
203
+ // ---------------------------------------------------------------------------
204
+
205
+ function LeftList({
206
+ entries,
207
+ activeId,
208
+ onJump,
209
+ itemRefs,
210
+ }: {
211
+ readonly entries: readonly FlatEntry[];
212
+ readonly activeId: string | null;
213
+ readonly onJump: (modelId: string) => void;
214
+ readonly itemRefs: React.RefObject<Map<string, HTMLElement>>;
215
+ }) {
216
+ let lastGroup: string | undefined;
217
+ return (
218
+ <div className="py-1">
219
+ {entries.map((entry) => {
220
+ const groupId = entry.provider?.id ?? '(no provider)';
221
+ const header = groupId !== lastGroup ? groupId : undefined;
222
+ lastGroup = groupId;
223
+ const isDefault = entry.provider?.default_model === entry.item.model;
224
+ const active = entry.item.model === activeId;
225
+ return (
226
+ <div key={entry.item.model}>
227
+ {header !== undefined ? (
228
+ <div className="flex items-center gap-1.5 px-3 pb-1 pt-2.5 text-[10px] font-semibold uppercase tracking-wider text-neutral-600">
229
+ <span className="truncate">{header}</span>
230
+ {entry.provider !== undefined ? (
231
+ <Badge
232
+ tone={
233
+ entry.provider.status === 'connected'
234
+ ? 'green'
235
+ : entry.provider.status === 'error'
236
+ ? 'red'
237
+ : 'amber'
238
+ }
239
+ >
240
+ {entry.provider.status}
241
+ </Badge>
242
+ ) : null}
243
+ </div>
244
+ ) : null}
245
+ <div
246
+ ref={(el) => {
247
+ if (el === null) itemRefs.current.delete(entry.item.model);
248
+ else itemRefs.current.set(entry.item.model, el);
249
+ }}
250
+ className={`cursor-pointer px-3 py-1.5 ${
251
+ active ? 'bg-sky-950/60' : 'hover:bg-neutral-800/60'
252
+ }`}
253
+ onClick={() => {
254
+ onJump(entry.item.model);
255
+ }}
256
+ >
257
+ <div className="flex items-center gap-1.5">
258
+ <span
259
+ className={`min-w-0 flex-1 truncate font-mono text-[11px] ${
260
+ active ? 'text-neutral-100' : 'text-neutral-300'
261
+ }`}
262
+ title={entry.item.model}
263
+ >
264
+ {entry.item.model}
265
+ </span>
266
+ {isDefault ? <Badge tone="sky">default</Badge> : null}
267
+ </div>
268
+ {entry.item.display_name !== undefined &&
269
+ entry.item.display_name !== entry.item.model ? (
270
+ <div className="truncate text-[10px] text-neutral-600">
271
+ {entry.item.display_name}
272
+ </div>
273
+ ) : null}
274
+ </div>
275
+ </div>
276
+ );
277
+ })}
278
+ </div>
279
+ );
280
+ }
281
+
282
+ // ---------------------------------------------------------------------------
283
+ // center column
284
+ // ---------------------------------------------------------------------------
285
+
286
+ function ModelSection({
287
+ entry,
288
+ onOpenSession,
289
+ registerRef,
290
+ }: {
291
+ readonly entry: FlatEntry;
292
+ readonly onOpenSession: (sessionId: string) => void;
293
+ readonly registerRef: (el: HTMLElement | null) => void;
294
+ }) {
295
+ const { klient, baseUrl, config } = useConnection();
296
+ const { item, provider } = entry;
297
+ const [ping, setPing] = useState<
298
+ | { readonly status: 'idle' | 'running' }
299
+ | { readonly status: 'done'; readonly result: ModelPingResult }
300
+ >({ status: 'idle' });
301
+ const [creating, setCreating] = useState(false);
302
+ const [sessionError, setSessionError] = useState<unknown>(null);
303
+
304
+ const runPing = async () => {
305
+ setPing({ status: 'running' });
306
+ try {
307
+ const result = await klient.core(IModelCatalog).ping(item.model);
308
+ setPing({ status: 'done', result });
309
+ } catch (error) {
310
+ setPing({
311
+ status: 'done',
312
+ result: { ok: false, durationMs: 0, error: errorMessage(error) },
313
+ });
314
+ }
315
+ };
316
+
317
+ // Session creation itself is v1 REST (klient is v2-only); resume + model
318
+ // selection then go through the channel layer before opening the chat tab.
319
+ const createSession = async () => {
320
+ const cwd = window.prompt('Working directory for the new session:', '');
321
+ if (cwd === null || cwd.trim() === '') return;
322
+ setCreating(true);
323
+ setSessionError(null);
324
+ try {
325
+ const headers: Record<string, string> = { 'content-type': 'application/json' };
326
+ if (config.token.trim() !== '') headers['authorization'] = `Bearer ${config.token.trim()}`;
327
+ const res = await fetch(`${baseUrl}/api/v1/sessions`, {
328
+ method: 'POST',
329
+ headers,
330
+ body: JSON.stringify({ metadata: { cwd: cwd.trim() } }),
331
+ });
332
+ const envelope = (await res.json()) as { code: number; msg: string; data: { id: string } };
333
+ if (envelope.code !== 0) throw new Error(envelope.msg);
334
+ const sessionId = envelope.data.id;
335
+ await klient.core(ISessionManager).resume(sessionId);
336
+ await klient
337
+ .session(sessionId)
338
+ .agent('main')
339
+ .service(IAgentProfileService)
340
+ .setModel(item.model);
341
+ onOpenSession(sessionId);
342
+ } catch (error) {
343
+ setSessionError(error);
344
+ } finally {
345
+ setCreating(false);
346
+ }
347
+ };
348
+
349
+ return (
350
+ <section ref={registerRef} className="border-b border-neutral-800 px-4 py-3">
351
+ <header className="mb-1 flex flex-wrap items-center gap-2">
352
+ <span className="font-mono text-[12px] font-semibold text-neutral-100">{item.model}</span>
353
+ {provider?.default_model === item.model ? <Badge tone="sky">default</Badge> : null}
354
+ <span className="text-[10px] text-neutral-600">{provider?.id ?? '(no provider)'}</span>
355
+ <ActionButton disabled={ping.status === 'running'} onClick={runPing}>
356
+ {ping.status === 'running' ? 'pinging…' : 'Ping'}
357
+ </ActionButton>
358
+ <ActionButton disabled={creating} onClick={createSession}>
359
+ {creating ? 'creating…' : '+ Session'}
360
+ </ActionButton>
361
+ <div className="flex-1" />
362
+ {(item.capabilities ?? []).map((capability) => (
363
+ <Badge key={capability}>{capability}</Badge>
364
+ ))}
365
+ {item.default_effort !== undefined ? (
366
+ <Badge tone="neutral">effort: {item.default_effort}</Badge>
367
+ ) : null}
368
+ <span className="shrink-0 font-mono text-[11px] text-neutral-500">
369
+ {formatContextSize(item.max_context_size)}
370
+ </span>
371
+ </header>
372
+ {ping.status === 'done' ? (
373
+ <div className="mb-1 flex flex-wrap items-center gap-2 border border-neutral-800/60 bg-neutral-900/50 px-2 py-1">
374
+ {ping.result.ok ? (
375
+ <>
376
+ <Badge tone="green">pong · {ping.result.durationMs}ms</Badge>
377
+ {ping.result.text !== undefined && ping.result.text !== '' ? (
378
+ <span className="text-[11px] text-neutral-300">{ping.result.text}</span>
379
+ ) : null}
380
+ {ping.result.usage !== undefined ? (
381
+ <span className="text-[10px] text-neutral-600">{usageLine(ping.result.usage)}</span>
382
+ ) : null}
383
+ {ping.result.finishReason !== undefined ? (
384
+ <span className="text-[10px] text-neutral-600">{ping.result.finishReason}</span>
385
+ ) : null}
386
+ </>
387
+ ) : (
388
+ <>
389
+ <Badge tone="red">ping failed · {ping.result.durationMs}ms</Badge>
390
+ <span className="text-[11px] text-red-400">{ping.result.error}</span>
391
+ </>
392
+ )}
393
+ </div>
394
+ ) : null}
395
+ {sessionError !== null ? <ErrorLine error={sessionError} /> : null}
396
+ </section>
397
+ );
398
+ }
399
+
400
+ function usageLine(usage: TokenUsage): string {
401
+ const input = usage.inputOther + usage.inputCacheRead + usage.inputCacheCreation;
402
+ return `in ${input} · out ${usage.output}`;
403
+ }
404
+
405
+ function formatContextSize(size: number): string {
406
+ if (size <= 0) return '—';
407
+ if (size >= 1_000_000) {
408
+ const millions = size / 1_000_000;
409
+ return `${millions.toFixed(Number.isInteger(millions) ? 0 : 1)}M ctx`;
410
+ }
411
+ if (size >= 1_000) return `${Math.round(size / 1_000)}k ctx`;
412
+ return `${size} ctx`;
413
+ }
apps/kimi-inspect/src/components/NavRail.tsx ADDED
@@ -0,0 +1,152 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Left icon rail — the app-level view switcher. Icon-only by design; view
3
+ * names live in tooltips. Adding a view is one `VIEWS` entry here plus its
4
+ * render branch in `App`.
5
+ */
6
+
7
+ import type { ReactNode } from 'react';
8
+
9
+ export type AppView =
10
+ | 'chat'
11
+ | 'search'
12
+ | 'models'
13
+ | 'services'
14
+ | 'workspace'
15
+ | 'suggest'
16
+ | 'bash'
17
+ | 'di';
18
+
19
+ interface ViewDef {
20
+ readonly id: AppView;
21
+ readonly title: string;
22
+ readonly icon: ReactNode;
23
+ }
24
+
25
+ const iconProps = {
26
+ width: 18,
27
+ height: 18,
28
+ viewBox: '0 0 24 24',
29
+ fill: 'none',
30
+ stroke: 'currentColor',
31
+ strokeWidth: 2,
32
+ strokeLinecap: 'round',
33
+ strokeLinejoin: 'round',
34
+ } as const;
35
+
36
+ const VIEWS: readonly ViewDef[] = [
37
+ {
38
+ id: 'chat',
39
+ title: 'Chat',
40
+ icon: (
41
+ <svg {...iconProps}>
42
+ <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
43
+ </svg>
44
+ ),
45
+ },
46
+ {
47
+ id: 'search',
48
+ title: 'Search',
49
+ icon: (
50
+ <svg {...iconProps}>
51
+ <circle cx="11" cy="11" r="8" />
52
+ <path d="m21 21-4.3-4.3" />
53
+ </svg>
54
+ ),
55
+ },
56
+ {
57
+ id: 'models',
58
+ title: 'Model Catalog',
59
+ icon: (
60
+ <svg {...iconProps}>
61
+ <path d="M12 2 2 7l10 5 10-5-10-5z" />
62
+ <path d="m2 17 10 5 10-5" />
63
+ <path d="m2 12 10 5 10-5" />
64
+ </svg>
65
+ ),
66
+ },
67
+ {
68
+ id: 'services',
69
+ title: 'App Services',
70
+ icon: (
71
+ <svg {...iconProps}>
72
+ <rect x="2" y="2" width="20" height="8" rx="2" />
73
+ <rect x="2" y="14" width="20" height="8" rx="2" />
74
+ <line x1="6" y1="6" x2="6.01" y2="6" />
75
+ <line x1="6" y1="18" x2="6.01" y2="18" />
76
+ </svg>
77
+ ),
78
+ },
79
+ {
80
+ id: 'workspace',
81
+ title: 'Workspace Runtime',
82
+ icon: (
83
+ <svg {...iconProps}>
84
+ <path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z" />
85
+ </svg>
86
+ ),
87
+ },
88
+ {
89
+ id: 'suggest',
90
+ title: 'Filesystem Suggest',
91
+ icon: (
92
+ <svg {...iconProps}>
93
+ <path d="M4 4h6l2 2h8v14H4z" />
94
+ <path d="m9 14 2 2 4-4" />
95
+ </svg>
96
+ ),
97
+ },
98
+ {
99
+ id: 'bash',
100
+ title: 'Bash Parser',
101
+ icon: (
102
+ <svg {...iconProps}>
103
+ <polyline points="4 17 10 11 4 5" />
104
+ <line x1="12" y1="19" x2="20" y2="19" />
105
+ </svg>
106
+ ),
107
+ },
108
+ {
109
+ id: 'di',
110
+ title: 'DI',
111
+ icon: (
112
+ <svg {...iconProps}>
113
+ <circle cx="18" cy="5" r="3" />
114
+ <circle cx="6" cy="12" r="3" />
115
+ <circle cx="18" cy="19" r="3" />
116
+ <line x1="8.6" y1="10.5" x2="15.4" y2="6.5" />
117
+ <line x1="8.6" y1="13.5" x2="15.4" y2="17.5" />
118
+ </svg>
119
+ ),
120
+ },
121
+ ];
122
+
123
+ export function NavRail({
124
+ view,
125
+ onChange,
126
+ }: {
127
+ readonly view: AppView;
128
+ readonly onChange: (view: AppView) => void;
129
+ }) {
130
+ return (
131
+ <nav className="flex w-11 shrink-0 flex-col items-center gap-1 border-r border-neutral-800 py-2">
132
+ {VIEWS.map((v) => (
133
+ <button
134
+ key={v.id}
135
+ type="button"
136
+ title={v.title}
137
+ aria-label={v.title}
138
+ onClick={() => {
139
+ onChange(v.id);
140
+ }}
141
+ className={`rounded-md p-2 transition-colors ${
142
+ view === v.id
143
+ ? 'bg-neutral-800 text-sky-400'
144
+ : 'text-neutral-500 hover:bg-neutral-800/60 hover:text-neutral-300'
145
+ }`}
146
+ >
147
+ {v.icon}
148
+ </button>
149
+ ))}
150
+ </nav>
151
+ );
152
+ }
apps/kimi-inspect/src/components/RightPanel.tsx ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Right dock — the single right-hand column of the chat view. Merges what
3
+ * used to be three separate columns (the transcript audit panel docked
4
+ * inside the chat view, the agent inspector on the far right, and the
5
+ * session pane next to the session list) into one tabbed column: `Audit`
6
+ * replays how the visible transcript store was built, entry by entry;
7
+ * `Agent` hosts the agent switcher, the Plan lookup card, and the agent
8
+ * Service panels; `State` reads the active agent's registered plain-data
9
+ * state through `IAgentStateService.snapshot()`; `Session` embeds
10
+ * `SessionPane` (session Services / State tabs). Tabs switch with `hidden`
11
+ * instead of unmounting, so panel-local state (the audit timeline position,
12
+ * Plan lookup input/results, expanded Service panels, the state tree's open
13
+ * rows) survives tab switches.
14
+ */
15
+
16
+ import { IAgentStateService } from '@moonshot-ai/agent-core-v2/agent/state/agentState';
17
+ import { useState } from 'react';
18
+
19
+ import type { AuditTrail } from '../audit/trail';
20
+ import { useConnection } from '../connection';
21
+ import { Badge } from '../ui';
22
+ import { AuditPanel } from './audit/AuditPanel';
23
+ import { Inspector } from './Inspector';
24
+ import { SessionPane } from './SessionPane';
25
+ import { StateCard } from './StateCard';
26
+
27
+ type Tab = 'audit' | 'agent' | 'state' | 'session';
28
+
29
+ export function RightPanel({
30
+ sessionId,
31
+ agentId,
32
+ onAgentChange,
33
+ ready,
34
+ trail,
35
+ }: {
36
+ sessionId: string | null;
37
+ agentId: string;
38
+ onAgentChange: (agentId: string) => void;
39
+ ready: boolean;
40
+ /** The chat view's audit trail; null until its transcript channel exists. */
41
+ trail: AuditTrail | null;
42
+ }) {
43
+ const { klient } = useConnection();
44
+ const [tab, setTab] = useState<Tab>('audit');
45
+
46
+ return (
47
+ <div className="flex h-full w-[440px] shrink-0 flex-col border-l border-neutral-800 bg-neutral-900/30">
48
+ <div className="flex border-b border-neutral-800 text-[11px]">
49
+ {(['audit', 'agent', 'state', 'session'] as const).map((t) => (
50
+ <button
51
+ key={t}
52
+ className={`flex-1 px-2 py-2 font-medium uppercase tracking-wider ${
53
+ tab === t ? 'bg-neutral-800 text-sky-400' : 'text-neutral-500 hover:text-neutral-300'
54
+ }`}
55
+ onClick={() => setTab(t)}
56
+ >
57
+ {t === 'audit' ? 'Audit' : t === 'agent' ? 'Agent' : t === 'state' ? 'State' : 'Session'}
58
+ </button>
59
+ ))}
60
+ </div>
61
+ <div className={tab === 'audit' ? 'flex min-h-0 flex-1 flex-col' : 'hidden'}>
62
+ {trail !== null ? (
63
+ <AuditPanel trail={trail} />
64
+ ) : (
65
+ <div className="p-3 text-[12px] text-neutral-600">
66
+ {sessionId === null
67
+ ? 'No session selected.'
68
+ : 'Loading transcript — the audit trail appears once the channel is up.'}
69
+ </div>
70
+ )}
71
+ </div>
72
+ <div className={tab === 'agent' ? 'flex min-h-0 flex-1 flex-col' : 'hidden'}>
73
+ <Inspector
74
+ sessionId={sessionId}
75
+ agentId={agentId}
76
+ onAgentChange={onAgentChange}
77
+ ready={ready}
78
+ />
79
+ </div>
80
+ <div className={tab === 'state' ? 'min-h-0 flex-1 overflow-y-auto' : 'hidden'}>
81
+ <div className="p-3">
82
+ {sessionId === null || !ready ? (
83
+ <div className="text-[12px] text-neutral-600">
84
+ {sessionId === null ? 'No session selected.' : 'Loading session…'}
85
+ </div>
86
+ ) : (
87
+ <StateCard
88
+ id={`${sessionId}/${agentId}`}
89
+ queryKey={['agentState', sessionId, agentId]}
90
+ title="Agent state"
91
+ label="agentStateService"
92
+ badge={<Badge tone="sky">{agentId}</Badge>}
93
+ fetchSnapshot={() =>
94
+ klient.session(sessionId).agent(agentId).service(IAgentStateService).snapshot()
95
+ }
96
+ />
97
+ )}
98
+ </div>
99
+ </div>
100
+ <div className={tab === 'session' ? 'flex min-h-0 flex-1 flex-col' : 'hidden'}>
101
+ <SessionPane sessionId={sessionId} ready={ready} />
102
+ </div>
103
+ </div>
104
+ );
105
+ }
apps/kimi-inspect/src/components/SearchView.tsx ADDED
@@ -0,0 +1,254 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Search view — the global cross-session message search (`POST /api/v1/search`).
3
+ * Full width like the other non-chat views. Results are pageable (Load more);
4
+ * clicking a hit hands it to the app shell (`onOpenResult`), which switches
5
+ * to the chat view at the hit's session / agent / turn / step.
6
+ */
7
+
8
+ import { useState } from 'react';
9
+
10
+ import { useConnection } from '../connection';
11
+ import { fetchSearchPage, type SearchHit, type SearchIndexState } from '../search/api';
12
+ import { ActionButton, Badge, ErrorLine, relTime } from '../ui';
13
+
14
+ type RoleFilter = 'all' | 'user' | 'assistant' | 'title';
15
+ type SortOrder = 'score' | 'time_desc' | 'time_asc';
16
+ type SearchMode = 'terms' | 'literal';
17
+
18
+ const PAGE_SIZE = 20;
19
+
20
+ interface ExecutedSearch {
21
+ readonly query: string;
22
+ readonly role?: 'user' | 'assistant' | 'title' | undefined;
23
+ readonly sort: SortOrder;
24
+ readonly mode: SearchMode;
25
+ readonly items: readonly SearchHit[];
26
+ readonly hasMore: boolean;
27
+ readonly pageToken?: string | undefined;
28
+ readonly incomplete?: 'candidate_cap' | undefined;
29
+ readonly source?: 'live' | 'index' | undefined;
30
+ readonly indexState: SearchIndexState;
31
+ }
32
+
33
+ export function SearchView({ onOpenResult }: { onOpenResult: (hit: SearchHit) => void }) {
34
+ const { baseUrl, config } = useConnection();
35
+ const [input, setInput] = useState('');
36
+ const [role, setRole] = useState<RoleFilter>('all');
37
+ const [sort, setSort] = useState<SortOrder>('score');
38
+ const [exact, setExact] = useState(false);
39
+ const [result, setResult] = useState<ExecutedSearch | null>(null);
40
+ const [searching, setSearching] = useState(false);
41
+ const [loadingMore, setLoadingMore] = useState(false);
42
+ const [error, setError] = useState<unknown>(null);
43
+
44
+ const runSearch = async () => {
45
+ const query = input.trim();
46
+ if (query === '' || searching) return;
47
+ setSearching(true);
48
+ setError(null);
49
+ try {
50
+ const token = config.token.trim();
51
+ const mode: SearchMode = exact ? 'literal' : 'terms';
52
+ const page = await fetchSearchPage({
53
+ baseUrl,
54
+ token: token === '' ? undefined : token,
55
+ query,
56
+ role: role === 'all' ? undefined : role,
57
+ sort,
58
+ mode,
59
+ pageSize: PAGE_SIZE,
60
+ });
61
+ setResult({
62
+ query,
63
+ role: role === 'all' ? undefined : role,
64
+ sort,
65
+ mode,
66
+ items: page.items,
67
+ hasMore: page.hasMore,
68
+ pageToken: page.pageToken,
69
+ incomplete: page.incomplete,
70
+ source: page.source,
71
+ indexState: page.indexState,
72
+ });
73
+ } catch (error) {
74
+ setError(error);
75
+ } finally {
76
+ setSearching(false);
77
+ }
78
+ };
79
+
80
+ const loadMore = async () => {
81
+ if (result === null || !result.hasMore || loadingMore) return;
82
+ setLoadingMore(true);
83
+ setError(null);
84
+ try {
85
+ const token = config.token.trim();
86
+ const page = await fetchSearchPage({
87
+ baseUrl,
88
+ token: token === '' ? undefined : token,
89
+ query: result.query,
90
+ role: result.role,
91
+ sort: result.sort,
92
+ mode: result.mode,
93
+ pageSize: PAGE_SIZE,
94
+ pageToken: result.pageToken,
95
+ });
96
+ setResult({
97
+ ...result,
98
+ items: [...result.items, ...page.items],
99
+ hasMore: page.hasMore,
100
+ pageToken: page.pageToken,
101
+ incomplete: page.incomplete,
102
+ source: page.source,
103
+ indexState: page.indexState,
104
+ });
105
+ } catch (error) {
106
+ setError(error);
107
+ } finally {
108
+ setLoadingMore(false);
109
+ }
110
+ };
111
+
112
+ return (
113
+ <div className="flex min-w-0 flex-1 flex-col">
114
+ <div className="flex items-center gap-2 border-b border-neutral-800 px-4 py-2">
115
+ <input
116
+ className="w-96 rounded border border-neutral-700 bg-neutral-950 px-3 py-1.5 text-[13px] text-neutral-100 outline-none focus:border-sky-600"
117
+ placeholder="Search messages across all sessions…"
118
+ value={input}
119
+ onChange={(e) => setInput(e.target.value)}
120
+ onKeyDown={(e) => {
121
+ if (e.key === 'Enter') {
122
+ e.preventDefault();
123
+ void runSearch();
124
+ }
125
+ }}
126
+ />
127
+ <select
128
+ className="rounded border border-neutral-700 bg-neutral-950 px-2 py-1.5 text-[12px] text-neutral-300 outline-none"
129
+ value={role}
130
+ onChange={(e) => setRole(e.target.value as RoleFilter)}
131
+ title="Document role"
132
+ >
133
+ <option value="all">all roles</option>
134
+ <option value="user">user</option>
135
+ <option value="assistant">assistant</option>
136
+ <option value="title">title</option>
137
+ </select>
138
+ <select
139
+ className="rounded border border-neutral-700 bg-neutral-950 px-2 py-1.5 text-[12px] text-neutral-300 outline-none disabled:opacity-40"
140
+ value={sort}
141
+ onChange={(e) => setSort(e.target.value as SortOrder)}
142
+ title={exact ? 'Exact match always sorts by newest first' : 'Sort order'}
143
+ disabled={exact}
144
+ >
145
+ <option value="score">relevance</option>
146
+ <option value="time_desc">newest first</option>
147
+ <option value="time_asc">oldest first</option>
148
+ </select>
149
+ <label
150
+ className="flex cursor-pointer items-center gap-1.5 rounded border border-neutral-700 bg-neutral-950 px-2 py-1.5 text-[12px] text-neutral-300 select-none"
151
+ title="Substring match, case-insensitive (slower, matches symbols like C++)"
152
+ >
153
+ <input
154
+ type="checkbox"
155
+ className="accent-sky-600"
156
+ checked={exact}
157
+ onChange={(e) => setExact(e.target.checked)}
158
+ />
159
+ exact match
160
+ </label>
161
+ <ActionButton onClick={() => void runSearch()} disabled={searching || input.trim() === ''}>
162
+ {searching ? 'Searching…' : 'Search'}
163
+ </ActionButton>
164
+ {result !== null ? (
165
+ <span className="ml-2 text-[11px] text-neutral-600">
166
+ {result.indexState.state === 'building'
167
+ ? `index building (${result.indexState.indexedSessions}/${result.indexState.totalSessions} sessions) — results may be incomplete`
168
+ : `${result.indexState.documents} documents indexed (${result.indexState.state})`}
169
+ </span>
170
+ ) : null}
171
+ {result?.source !== undefined ? (
172
+ <span
173
+ className="ml-2"
174
+ title={
175
+ result.source === 'live'
176
+ ? 'searched the in-memory session transcript'
177
+ : 'searched the persisted search index'
178
+ }
179
+ >
180
+ <Badge tone={result.source === 'live' ? 'green' : 'neutral'}>
181
+ source: {result.source}
182
+ </Badge>
183
+ </span>
184
+ ) : null}
185
+ </div>
186
+
187
+ <div className="flex-1 overflow-y-auto px-4 py-3">
188
+ {error !== null ? (
189
+ <div className="mb-3">
190
+ <ErrorLine error={error} />
191
+ </div>
192
+ ) : null}
193
+ {result === null ? (
194
+ <div className="text-[12px] text-neutral-600 italic">
195
+ Search user prompts, assistant replies and session titles across every session.
196
+ </div>
197
+ ) : result.items.length === 0 && !searching ? (
198
+ <div className="text-[12px] text-neutral-600 italic">No hits for “{result.query}”.</div>
199
+ ) : (
200
+ <div className="flex flex-col gap-2">
201
+ {result.incomplete === 'candidate_cap' ? (
202
+ <div className="text-[11px] text-neutral-600">
203
+ too many matches — the candidate set was truncated, results may be incomplete
204
+ </div>
205
+ ) : null}
206
+ {result.items.map((hit, i) => (
207
+ <HitCard
208
+ key={`${hit.sessionId}/${hit.agentId}/${hit.time}/${i}`}
209
+ hit={hit}
210
+ onOpen={onOpenResult}
211
+ />
212
+ ))}
213
+ {result.hasMore ? (
214
+ <div className="mt-1 flex justify-center">
215
+ <ActionButton onClick={() => void loadMore()} disabled={loadingMore}>
216
+ {loadingMore ? 'Loading…' : 'Load more'}
217
+ </ActionButton>
218
+ </div>
219
+ ) : null}
220
+ </div>
221
+ )}
222
+ </div>
223
+ </div>
224
+ );
225
+ }
226
+
227
+ function HitCard({ hit, onOpen }: { hit: SearchHit; onOpen: (hit: SearchHit) => void }) {
228
+ return (
229
+ <button
230
+ type="button"
231
+ className="w-full rounded-lg border border-neutral-800 bg-neutral-900/40 px-3 py-2 text-left transition-colors hover:border-sky-800 hover:bg-neutral-900"
232
+ onClick={() => onOpen(hit)}
233
+ title="Open in chat"
234
+ >
235
+ <div className="mb-1 flex flex-wrap items-center gap-2">
236
+ <span className="text-[12px] font-medium text-neutral-200">
237
+ {hit.sessionTitle !== '' ? hit.sessionTitle : '(untitled session)'}
238
+ </span>
239
+ <Badge tone={hit.role === 'user' ? 'sky' : hit.role === 'assistant' ? 'green' : 'violet'}>
240
+ {hit.role}
241
+ </Badge>
242
+ {hit.agentId !== '' ? <Badge tone="neutral">agent: {hit.agentId}</Badge> : null}
243
+ <span className="ml-auto text-[10px] text-neutral-600">{relTime(hit.time)}</span>
244
+ </div>
245
+ <div className="mb-1 whitespace-pre-wrap text-[12px] text-neutral-300">{hit.snippet}</div>
246
+ <div className="flex flex-wrap items-center gap-2 font-mono text-[10px] text-neutral-600">
247
+ <span>{hit.sessionId}</span>
248
+ {hit.turn !== undefined ? <span>t{hit.turn}</span> : null}
249
+ {hit.stepId !== undefined ? <span>{hit.stepId}</span> : null}
250
+ <span>score {hit.score.toFixed(2)}</span>
251
+ </div>
252
+ </button>
253
+ );
254
+ }
apps/kimi-inspect/src/components/ServerSwitcher.tsx ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Header server switcher — lists the locally discovered kap-servers (dev
3
+ * middleware `/__inspect/servers`) and switches the connection between them
4
+ * without a reload. Discovered picks are not persisted as a full connection
5
+ * config; only the picked URL is remembered so a reload re-picks it while the
6
+ * instance is still alive. Falls back to a plain read-only URL when discovery
7
+ * is unavailable (static hosting) or finds no local servers.
8
+ */
9
+
10
+ import { useConnection } from '../connection';
11
+ import { useServerDiscovery } from '../servers';
12
+
13
+ export function ServerSwitcher() {
14
+ const { baseUrl, connect } = useConnection();
15
+ const discovery = useServerDiscovery();
16
+ const data = discovery.data;
17
+ if (data === null || data === undefined || data.servers.length === 0) {
18
+ // No local discovery (static hosting / no servers): plain read-only URL.
19
+ return <span className="font-mono text-[10px] text-neutral-500">{baseUrl}</span>;
20
+ }
21
+ const current = data.servers.find((s) => s.url === baseUrl);
22
+ return (
23
+ <select
24
+ className="rounded border border-neutral-700 bg-neutral-950 px-1.5 py-0.5 font-mono text-[10px] text-neutral-300 outline-none focus:border-sky-600"
25
+ title={`Local kap-servers (${data.home})`}
26
+ value={current?.id ?? '__custom'}
27
+ onChange={(e) => {
28
+ const target = data.servers.find((s) => s.id === e.target.value);
29
+ if (target === undefined || target.url === baseUrl) return;
30
+ connect(
31
+ { url: target.url, token: data.token ?? '' },
32
+ { persist: false, rememberServerUrl: target.url },
33
+ );
34
+ }}
35
+ >
36
+ {current === undefined ? (
37
+ <option value="__custom">custom: {baseUrl.replace(/^https?:\/\//, '')}</option>
38
+ ) : null}
39
+ {data.servers.map((s) => (
40
+ <option key={s.id} value={s.id}>
41
+ {s.url.replace(/^https?:\/\//, '')}
42
+ {s.pid !== undefined ? ` · pid ${s.pid}` : ''}
43
+ {s.source === 'proxy' ? ' · proxy' : ''}
44
+ </option>
45
+ ))}
46
+ </select>
47
+ );
48
+ }
apps/kimi-inspect/src/components/ServicePanels.tsx ADDED
@@ -0,0 +1,805 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Scope panels — the merged Service list for one wire scope (`app` /
3
+ * `session` / `agent`), shared by the chat inspector (session + agent tabs)
4
+ * and the standalone App Services rail view. The baseline is the dynamic
5
+ * channel list served by `GET /api/v1/debug/channels` (every wire-exposed
6
+ * Service with its methods), rendered by `DynamicServiceCard`; the
7
+ * handwritten descriptors in `panels.ts` override individual Services with
8
+ * curated cards (`ServiceCard`).
9
+ *
10
+ * Two layouts consume the same merged list (`useScopePanels`):
11
+ * - `ScopePanels`: a plain stacked card list (the chat inspector).
12
+ * - `ScopePanelsScrollspy`: a Postman-style three-pane layout — Service
13
+ * list on the left with a fuzzy filter box, every Service's card
14
+ * expanded (no collapsing) in one continuously scrolling column in the
15
+ * middle (scroll position drives the left-side highlight, clicks scroll
16
+ * the middle), and a request/response call history on the right fed by
17
+ * recording proxies around every card. Card headers copy the Service
18
+ * name on click (`CopyableName`).
19
+ *
20
+ * Everything here is fetch-on-demand (Load / Refresh buttons): the v2 event
21
+ * socket (`/api/v2/ws`) that used to push core/session/agent event streams
22
+ * was removed server-side, so there is no live push to render.
23
+ */
24
+
25
+ import { useQuery } from '@tanstack/react-query';
26
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
27
+
28
+ import { fetchChannelDescriptors, type ChannelDescriptor, type ChannelScope } from '../channel';
29
+ import { useConnection } from '../connection';
30
+ import {
31
+ AGENT_PANELS,
32
+ CORE_PANELS,
33
+ SESSION_PANELS,
34
+ call,
35
+ type AnyService,
36
+ type ServicePanelDef,
37
+ } from '../panels';
38
+ import { ActionButton, Badge, ErrorLine, JsonView, errorMessage, relTime } from '../ui';
39
+ import { buildArgs, fieldKey, parseParamFields, type ParamField } from './methodArgs';
40
+
41
+ const PANEL_OVERRIDES: ReadonlyMap<string, ServicePanelDef> = new Map(
42
+ [...CORE_PANELS, ...SESSION_PANELS, ...AGENT_PANELS].map((def) => [def.id, def]),
43
+ );
44
+
45
+ /** One merged Service entry: the wire channel plus its curated override, if any. */
46
+ interface ScopeEntry {
47
+ readonly name: string;
48
+ readonly channel?: ChannelDescriptor;
49
+ readonly def?: ServicePanelDef;
50
+ }
51
+
52
+ /** Load the full protocol list once per connection (every channel, 1:1). */
53
+ function useChannels() {
54
+ const { klient } = useConnection();
55
+ return useQuery({
56
+ queryKey: ['channels', klient.baseUrl],
57
+ queryFn: () => fetchChannelDescriptors(klient),
58
+ staleTime: Number.POSITIVE_INFINITY,
59
+ });
60
+ }
61
+
62
+ /**
63
+ * Merge the dynamic channel list with the handwritten overrides for one
64
+ * scope. When the channels endpoint is unavailable, fall back to the
65
+ * handwritten panels only.
66
+ */
67
+ function useScopePanels(scope: ChannelScope) {
68
+ const channels = useChannels();
69
+ const entries = useMemo<readonly ScopeEntry[]>(() => {
70
+ const byName = new Map<string, ChannelDescriptor | undefined>();
71
+ if (channels.data !== undefined) {
72
+ for (const c of channels.data) {
73
+ if (c.scope === scope) byName.set(c.name, c);
74
+ }
75
+ // Keep overrides the introspection missed (e.g. server drift).
76
+ for (const def of PANEL_OVERRIDES.values()) {
77
+ if (def.scope === scope && !byName.has(def.id)) byName.set(def.id, undefined);
78
+ }
79
+ } else {
80
+ for (const def of PANEL_OVERRIDES.values()) {
81
+ if (def.scope === scope) byName.set(def.id, undefined);
82
+ }
83
+ }
84
+ return [...byName.entries()].map(([name, channel]) => ({
85
+ name,
86
+ channel,
87
+ def: PANEL_OVERRIDES.get(name),
88
+ }));
89
+ }, [channels.data, scope]);
90
+ return { entries, channels };
91
+ }
92
+
93
+ /**
94
+ * Render every Service of one scope as a plain stacked card list.
95
+ * `proxyFor` materializes the Service proxy for a channel name (null when
96
+ * the scope is not callable, e.g. no session selected); `onError` observes
97
+ * call failures (the agent switcher uses it to mark unloaded agents).
98
+ */
99
+ export function ScopePanels({
100
+ scope,
101
+ proxyFor,
102
+ onError,
103
+ }: {
104
+ readonly scope: ChannelScope;
105
+ readonly proxyFor: (name: string) => AnyService | null;
106
+ readonly onError?: (error: unknown) => void;
107
+ }) {
108
+ const { entries, channels } = useScopePanels(scope);
109
+
110
+ return (
111
+ <>
112
+ {channels.isError ? (
113
+ <div className="mb-2">
114
+ <ErrorLine error={channels.error} />
115
+ <div className="mt-1 text-[10px] text-neutral-600">
116
+ dynamic channel list unavailable — showing handwritten panels only
117
+ </div>
118
+ </div>
119
+ ) : null}
120
+ {entries.map(({ name, channel, def }) => {
121
+ if (def !== undefined) {
122
+ return <ServiceCard key={name} def={def} svc={proxyFor(name)} onError={onError} />;
123
+ }
124
+ if (channel === undefined) return null;
125
+ return (
126
+ <DynamicServiceCard key={name} channel={channel} svc={proxyFor(name)} onError={onError} />
127
+ );
128
+ })}
129
+ </>
130
+ );
131
+ }
132
+
133
+ /**
134
+ * Subsequence fuzzy match (case-insensitive): `ssm` matches
135
+ * `sessionScopeMetadata`. An empty query matches everything.
136
+ */
137
+ function fuzzyMatch(name: string, query: string): boolean {
138
+ const q = query.trim().toLowerCase();
139
+ if (q === '') return true;
140
+ const n = name.toLowerCase();
141
+ let i = 0;
142
+ for (const ch of n) {
143
+ if (ch === q[i]) i++;
144
+ if (i === q.length) return true;
145
+ }
146
+ return false;
147
+ }
148
+
149
+ /**
150
+ * Three-pane scope browser: the Service list on the left (with a fuzzy
151
+ * filter box), every Service's card expanded in one continuously scrolling
152
+ * column in the middle, and a Postman-style call history on the right
153
+ * (`HistoryPane`) recording every wire request/response the cards make.
154
+ * The scroll position highlights the matching list item (and keeps it
155
+ * visible); clicking a list item smooth-scrolls the middle column to that
156
+ * Service. The list hides below the `md` breakpoint and the history below
157
+ * `lg`.
158
+ */
159
+ export function ScopePanelsScrollspy({
160
+ scope,
161
+ title,
162
+ proxyFor,
163
+ onError,
164
+ }: {
165
+ readonly scope: ChannelScope;
166
+ readonly title: string;
167
+ readonly proxyFor: (name: string) => AnyService | null;
168
+ readonly onError?: (error: unknown) => void;
169
+ }) {
170
+ const { entries, channels } = useScopePanels(scope);
171
+ const [active, setActive] = useState<string | null>(null);
172
+ const [query, setQuery] = useState('');
173
+ const scrollRef = useRef<HTMLDivElement | null>(null);
174
+ const sectionRefs = useRef(new Map<string, HTMLElement>());
175
+ const navRefs = useRef(new Map<string, HTMLButtonElement>());
176
+
177
+ // The filter narrows the left list only; the middle column keeps every
178
+ // card mounted so in-flight inputs and results survive a search.
179
+ const filtered = useMemo(
180
+ () => entries.filter(({ name }) => fuzzyMatch(name, query)),
181
+ [entries, query],
182
+ );
183
+
184
+ // Call history: every card proxy is wrapped (`makeRecordingProxy`), so
185
+ // curated and dynamic cards record uniformly without per-card plumbing.
186
+ const [records, setRecords] = useState<readonly CallRecord[]>([]);
187
+ const [expandedId, setExpandedId] = useState<number | null>(null);
188
+ const recordId = useRef(0);
189
+ const record = useCallback((entry: Omit<CallRecord, 'id'>) => {
190
+ const id = ++recordId.current;
191
+ setRecords((prev) => [{ ...entry, id }, ...prev].slice(0, 100));
192
+ // Like Postman's response pane: the newest call is always open.
193
+ setExpandedId(id);
194
+ }, []);
195
+ const recordingProxyFor = useCallback(
196
+ (name: string): AnyService | null => {
197
+ const svc = proxyFor(name);
198
+ return svc === null ? null : makeRecordingProxy(name, svc, record);
199
+ },
200
+ [proxyFor, record],
201
+ );
202
+
203
+ // Highlight the section nearest the top of the scroll port. Driven by the
204
+ // scroll event (not IntersectionObserver) so tall sections and the tail of
205
+ // the list behave predictably. Section refs sit in DOM order in the map.
206
+ const syncActive = useCallback(() => {
207
+ const root = scrollRef.current;
208
+ if (root === null) return;
209
+ const rootTop = root.getBoundingClientRect().top;
210
+ let current: string | null = null;
211
+ for (const [name, el] of sectionRefs.current) {
212
+ if (el.getBoundingClientRect().top - rootTop <= 96) current = name;
213
+ else break;
214
+ }
215
+ current ??= entries[0]?.name ?? null;
216
+ setActive((prev) => (prev === current ? prev : current));
217
+ }, [entries]);
218
+
219
+ // Sections mount as the channel list resolves, so re-sync on list changes.
220
+ useEffect(() => {
221
+ syncActive();
222
+ }, [syncActive]);
223
+
224
+ // Keep the highlighted item inside the left list's viewport.
225
+ useEffect(() => {
226
+ if (active === null) return;
227
+ navRefs.current.get(active)?.scrollIntoView({ block: 'nearest' });
228
+ }, [active]);
229
+
230
+ const scrollTo = (name: string) => {
231
+ setActive(name);
232
+ sectionRefs.current.get(name)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
233
+ };
234
+
235
+ return (
236
+ <div className="flex min-h-0 min-w-0 flex-1">
237
+ <nav className="hidden w-56 shrink-0 overflow-y-auto border-r border-neutral-800 py-2 md:block">
238
+ <div className="px-3 pt-1 pb-2 text-[10px] font-semibold uppercase tracking-wider text-neutral-500">
239
+ {title}
240
+ </div>
241
+ <div className="px-2 pb-2">
242
+ <input
243
+ className="w-full rounded border border-neutral-700 bg-neutral-950 px-2 py-1 font-mono text-[11px] text-neutral-100 outline-none focus:border-sky-600"
244
+ placeholder="Filter services…"
245
+ value={query}
246
+ onChange={(e) => setQuery(e.target.value)}
247
+ />
248
+ </div>
249
+ {filtered.length === 0 ? (
250
+ <div className="px-3 py-1 text-[11px] text-neutral-600 italic">no matches</div>
251
+ ) : null}
252
+ {filtered.map(({ name, def }) => (
253
+ <button
254
+ key={name}
255
+ type="button"
256
+ ref={(el) => {
257
+ if (el === null) navRefs.current.delete(name);
258
+ else navRefs.current.set(name, el);
259
+ }}
260
+ onClick={() => scrollTo(name)}
261
+ className={`block w-full truncate px-3 py-1 text-left font-mono text-[11px] transition-colors ${
262
+ active === name
263
+ ? 'bg-neutral-800 text-sky-400'
264
+ : 'text-neutral-500 hover:bg-neutral-800/60 hover:text-neutral-300'
265
+ }`}
266
+ title={def?.label ?? name}
267
+ >
268
+ {name}
269
+ </button>
270
+ ))}
271
+ </nav>
272
+ <div
273
+ ref={scrollRef}
274
+ onScroll={syncActive}
275
+ className="min-w-0 flex-1 overflow-y-auto px-4 py-3"
276
+ >
277
+ {channels.isError ? (
278
+ <div className="mb-2">
279
+ <ErrorLine error={channels.error} />
280
+ <div className="mt-1 text-[10px] text-neutral-600">
281
+ dynamic channel list unavailable — showing handwritten panels only
282
+ </div>
283
+ </div>
284
+ ) : null}
285
+ {entries.map(({ name, channel, def }) => (
286
+ <section
287
+ key={name}
288
+ ref={(el) => {
289
+ if (el === null) sectionRefs.current.delete(name);
290
+ else sectionRefs.current.set(name, el);
291
+ }}
292
+ className="scroll-mt-3"
293
+ >
294
+ {def !== undefined ? (
295
+ <ServiceCard def={def} svc={recordingProxyFor(name)} onError={onError} />
296
+ ) : channel !== undefined ? (
297
+ <DynamicServiceCard
298
+ channel={channel}
299
+ svc={recordingProxyFor(name)}
300
+ onError={onError}
301
+ collapsible={false}
302
+ inlineResults={false}
303
+ />
304
+ ) : null}
305
+ </section>
306
+ ))}
307
+ </div>
308
+ <HistoryPane
309
+ records={records}
310
+ expandedId={expandedId}
311
+ onToggle={(id) => setExpandedId((prev) => (prev === id ? null : id))}
312
+ onClear={() => {
313
+ setRecords([]);
314
+ setExpandedId(null);
315
+ }}
316
+ />
317
+ </div>
318
+ );
319
+ }
320
+
321
+ // ---------------------------------------------------------------------------
322
+ // Call history — one record per wire call, newest first, capped at 100.
323
+ // ---------------------------------------------------------------------------
324
+
325
+ interface CallRecord {
326
+ readonly id: number;
327
+ readonly service: string;
328
+ readonly method: string;
329
+ readonly args: readonly unknown[];
330
+ readonly at: number;
331
+ readonly durationMs: number;
332
+ readonly ok: boolean;
333
+ readonly result?: unknown;
334
+ readonly error?: string;
335
+ }
336
+
337
+ /**
338
+ * Wrap a Service proxy so every method call is recorded (args, result or
339
+ * error, duration) before its promise settles. Errors rethrow unchanged, so
340
+ * the calling card's own error handling still runs.
341
+ */
342
+ function makeRecordingProxy(
343
+ service: string,
344
+ svc: AnyService,
345
+ record: (entry: Omit<CallRecord, 'id'>) => void,
346
+ ): AnyService {
347
+ return new Proxy(svc, {
348
+ get(target, prop) {
349
+ const member = target[prop as string];
350
+ if (typeof member !== 'function') return member;
351
+ return (...args: unknown[]) => {
352
+ const at = Date.now();
353
+ return Promise.resolve(member(...args)).then(
354
+ (result) => {
355
+ record({
356
+ service,
357
+ method: String(prop),
358
+ args,
359
+ at,
360
+ durationMs: Date.now() - at,
361
+ ok: true,
362
+ result,
363
+ });
364
+ return result;
365
+ },
366
+ (error: unknown) => {
367
+ record({
368
+ service,
369
+ method: String(prop),
370
+ args,
371
+ at,
372
+ durationMs: Date.now() - at,
373
+ ok: false,
374
+ error: errorMessage(error),
375
+ });
376
+ throw error;
377
+ },
378
+ );
379
+ };
380
+ },
381
+ });
382
+ }
383
+
384
+ function HistoryPane({
385
+ records,
386
+ expandedId,
387
+ onToggle,
388
+ onClear,
389
+ }: {
390
+ readonly records: readonly CallRecord[];
391
+ readonly expandedId: number | null;
392
+ readonly onToggle: (id: number) => void;
393
+ readonly onClear: () => void;
394
+ }) {
395
+ return (
396
+ <div className="hidden w-[360px] shrink-0 flex-col border-l border-neutral-800 bg-neutral-900/30 lg:flex">
397
+ <div className="flex items-center justify-between border-b border-neutral-800 px-3 py-2">
398
+ <span className="text-[10px] font-semibold uppercase tracking-wider text-neutral-500">
399
+ History {records.length > 0 ? `(${records.length})` : ''}
400
+ </span>
401
+ {records.length > 0 ? <ActionButton onClick={onClear}>Clear</ActionButton> : null}
402
+ </div>
403
+ <div className="flex-1 overflow-y-auto p-2">
404
+ {records.length === 0 ? (
405
+ <div className="px-1 text-[11px] text-neutral-600 italic">
406
+ call any method to see the request/response here
407
+ </div>
408
+ ) : null}
409
+ {records.map((r) => {
410
+ const open = r.id === expandedId;
411
+ return (
412
+ <div key={r.id} className="mb-2 rounded border border-neutral-800 bg-neutral-900/60">
413
+ <div
414
+ className="flex cursor-pointer items-center gap-2 px-2 py-1.5 select-none"
415
+ onClick={() => onToggle(r.id)}
416
+ >
417
+ <span
418
+ className={`shrink-0 text-[9px] ${r.ok ? 'text-emerald-400' : 'text-red-400'}`}
419
+ >
420
+ ●
421
+ </span>
422
+ <span className="min-w-0 flex-1 truncate font-mono text-[11px] text-neutral-200">
423
+ {r.service}.{r.method}
424
+ </span>
425
+ <span className="shrink-0 text-[10px] text-neutral-600">{r.durationMs}ms</span>
426
+ <span className="shrink-0 text-[10px] text-neutral-600">{relTime(r.at)}</span>
427
+ <span className="shrink-0 text-[10px] text-neutral-600">{open ? '▾' : '▸'}</span>
428
+ </div>
429
+ {open ? (
430
+ <div className="border-t border-neutral-800/60 px-2 py-1.5">
431
+ <div className="mb-0.5 text-[10px] font-semibold uppercase tracking-wider text-neutral-600">
432
+ Request
433
+ </div>
434
+ <JsonView data={r.args} empty="(no args)" />
435
+ <div className="mt-1.5 mb-0.5 text-[10px] font-semibold uppercase tracking-wider text-neutral-600">
436
+ {r.ok ? 'Response' : 'Error'}
437
+ </div>
438
+ {r.ok ? (
439
+ <JsonView data={r.result} empty="(no result)" />
440
+ ) : (
441
+ <div className="text-[11px] break-words text-red-400">{r.error}</div>
442
+ )}
443
+ </div>
444
+ ) : null}
445
+ </div>
446
+ );
447
+ })}
448
+ </div>
449
+ </div>
450
+ );
451
+ }
452
+
453
+ // ---------------------------------------------------------------------------
454
+ // Generic service card
455
+ // ---------------------------------------------------------------------------
456
+
457
+ /** Service name that copies itself to the clipboard on click. */
458
+ function CopyableName({ name, className }: { name: string; className?: string }) {
459
+ const [copied, setCopied] = useState(false);
460
+ return (
461
+ <button
462
+ type="button"
463
+ title={`click to copy: ${name}`}
464
+ className={className}
465
+ onClick={(e) => {
466
+ // Don't trigger the collapsible header toggle behind the name.
467
+ e.stopPropagation();
468
+ void navigator.clipboard.writeText(name).then(() => {
469
+ setCopied(true);
470
+ setTimeout(() => setCopied(false), 1200);
471
+ });
472
+ }}
473
+ >
474
+ {copied ? 'copied ✓' : name}
475
+ </button>
476
+ );
477
+ }
478
+
479
+ function ServiceCard({
480
+ def,
481
+ svc,
482
+ onError,
483
+ }: {
484
+ def: ServicePanelDef;
485
+ svc: AnyService | null;
486
+ onError?: (error: unknown) => void;
487
+ }) {
488
+ const [data, setData] = useState<unknown>(undefined);
489
+ const [error, setError] = useState<unknown>(null);
490
+ const [busy, setBusy] = useState<string | null>(null);
491
+ const [loaded, setLoaded] = useState(false);
492
+
493
+ const refresh = async () => {
494
+ if (svc === null || def.fetch === undefined) return;
495
+ try {
496
+ setError(null);
497
+ const result = await def.fetch(svc);
498
+ setData(result);
499
+ setLoaded(true);
500
+ } catch (error) {
501
+ setError(error);
502
+ onError?.(error);
503
+ }
504
+ };
505
+
506
+ return (
507
+ <div className="mb-3 rounded-lg border border-neutral-800 bg-neutral-900/60">
508
+ <div className="flex items-center justify-between border-b border-neutral-800/60 px-3 py-2">
509
+ <div>
510
+ <span className="text-[12px] font-medium text-neutral-200">{def.label}</span>
511
+ <CopyableName
512
+ name={def.id}
513
+ className="ml-2 font-mono text-[10px] text-neutral-600 hover:text-sky-400"
514
+ />
515
+ </div>
516
+ {def.fetch !== undefined ? (
517
+ <ActionButton onClick={() => void refresh()} disabled={svc === null}>
518
+ {loaded ? 'Refresh' : 'Load'}
519
+ </ActionButton>
520
+ ) : null}
521
+ </div>
522
+ <div className="px-3 py-2">
523
+ {error !== null ? (
524
+ <div className="mb-2">
525
+ <ErrorLine error={error} />
526
+ </div>
527
+ ) : null}
528
+ {def.fetch !== undefined ? (
529
+ loaded ? (
530
+ <JsonView data={data} />
531
+ ) : (
532
+ <div className="text-[11px] text-neutral-600 italic">
533
+ click Load to read this Service
534
+ </div>
535
+ )
536
+ ) : null}
537
+ {def.actions !== undefined && def.actions.length > 0 ? (
538
+ <div className="mt-2 flex flex-wrap gap-1.5">
539
+ {def.actions.map((action) => (
540
+ <ActionButton
541
+ key={action.label}
542
+ danger={action.danger}
543
+ disabled={svc === null || busy !== null}
544
+ onClick={async () => {
545
+ if (svc === null) return;
546
+ let input: string | undefined;
547
+ if (action.input !== undefined) {
548
+ const raw = window.prompt(action.input);
549
+ if (raw === null) return;
550
+ input = raw;
551
+ }
552
+ setBusy(action.label);
553
+ setError(null);
554
+ try {
555
+ const result = await action.run(svc, input);
556
+ if (result !== undefined && def.fetch === undefined) setData(result);
557
+ if (def.fetch !== undefined) await refresh();
558
+ } catch (error) {
559
+ setError(error);
560
+ onError?.(error);
561
+ } finally {
562
+ setBusy(null);
563
+ }
564
+ }}
565
+ >
566
+ {busy === action.label ? '…' : action.label}
567
+ </ActionButton>
568
+ ))}
569
+ </div>
570
+ ) : null}
571
+ {def.fetch === undefined && data !== undefined ? (
572
+ <div className="mt-2">
573
+ <JsonView data={data} />
574
+ </div>
575
+ ) : null}
576
+ </div>
577
+ </div>
578
+ );
579
+ }
580
+
581
+ // ---------------------------------------------------------------------------
582
+ // Dynamic service card — generic renderer for channels without a handwritten
583
+ // override. Every method gets a call button labeled with its declared
584
+ // signature; parameters become structured inputs (`MethodArgInputs`: one
585
+ // field per parameter, one per key for destructured objects, smart-parsed —
586
+ // see `methodArgs.ts`). Getters become read buttons. Results render inline.
587
+ // ---------------------------------------------------------------------------
588
+
589
+ function DynamicServiceCard({
590
+ channel,
591
+ svc,
592
+ onError,
593
+ collapsible = true,
594
+ inlineResults = true,
595
+ }: {
596
+ channel: ChannelDescriptor;
597
+ svc: AnyService | null;
598
+ onError?: (error: unknown) => void;
599
+ /**
600
+ * Allow collapsing the method list behind the header. Off in the
601
+ * scrollspy layout, where every card stays expanded and the header name
602
+ * is a copy target instead of a toggle.
603
+ */
604
+ collapsible?: boolean;
605
+ /**
606
+ * Render call results inline under each method. Off in the scrollspy
607
+ * layout, where results land in the right-side history pane instead.
608
+ */
609
+ inlineResults?: boolean;
610
+ }) {
611
+ const [open, setOpen] = useState(false);
612
+ const [inputs, setInputs] = useState<Record<string, Record<string, string>>>({});
613
+ const [results, setResults] = useState<Record<string, unknown>>({});
614
+ const [errors, setErrors] = useState<Record<string, unknown>>({});
615
+ const [busy, setBusy] = useState<string | null>(null);
616
+
617
+ const invoke = async (method: ChannelDescriptor['methods'][number]) => {
618
+ if (svc === null) return;
619
+ let argv: unknown[];
620
+ try {
621
+ argv = buildArgs(parseParamFields(method.params), inputs[method.name] ?? {});
622
+ } catch {
623
+ setErrors((prev) => ({ ...prev, [method.name]: new Error('arg is not valid JSON') }));
624
+ return;
625
+ }
626
+ setBusy(method.name);
627
+ setErrors((prev) => ({ ...prev, [method.name]: null }));
628
+ try {
629
+ const result = await call(svc, method.name, ...argv);
630
+ setResults((prev) => ({ ...prev, [method.name]: result ?? '(no result)' }));
631
+ } catch (error) {
632
+ setErrors((prev) => ({ ...prev, [method.name]: error }));
633
+ onError?.(error);
634
+ } finally {
635
+ setBusy(null);
636
+ }
637
+ };
638
+
639
+ return (
640
+ <div className="mb-3 rounded-lg border border-neutral-800 bg-neutral-900/60">
641
+ <div
642
+ className={`flex items-center justify-between px-3 py-2 ${
643
+ collapsible ? 'cursor-pointer select-none' : ''
644
+ }`}
645
+ onClick={collapsible ? () => setOpen((v) => !v) : undefined}
646
+ >
647
+ <div>
648
+ <CopyableName
649
+ name={channel.name}
650
+ className="text-[12px] font-medium text-neutral-300 hover:text-sky-400"
651
+ />
652
+ <span className="ml-2 text-[10px] text-neutral-600">
653
+ {channel.methods.length} methods · {channel.domain}
654
+ </span>
655
+ </div>
656
+ {collapsible ? (
657
+ <span className="text-[10px] text-neutral-600">{open ? '▾' : '▸'}</span>
658
+ ) : null}
659
+ </div>
660
+ {!collapsible || open ? (
661
+ <div className="border-t border-neutral-800/60 px-3 py-2">
662
+ {channel.methods.length === 0 ? (
663
+ <div className="text-[11px] text-neutral-600 italic">no callable members</div>
664
+ ) : null}
665
+ {channel.methods.map((m) => {
666
+ const fields = m.kind === 'method' ? parseParamFields(m.params) : [];
667
+ return (
668
+ <div key={m.name} className="mb-2 last:mb-0">
669
+ <div className="flex items-center gap-1.5">
670
+ <ActionButton
671
+ disabled={svc === null || busy !== null}
672
+ onClick={() => void invoke(m)}
673
+ >
674
+ {busy === m.name ? '…' : `${m.name}(${m.params})`}
675
+ </ActionButton>
676
+ {m.kind === 'property' ? <Badge tone="neutral">get</Badge> : null}
677
+ </div>
678
+ {fields.length > 0 ? (
679
+ <MethodArgInputs
680
+ fields={fields}
681
+ values={inputs[m.name] ?? {}}
682
+ onChange={(key, value) =>
683
+ setInputs((prev) => ({
684
+ ...prev,
685
+ [m.name]: { ...prev[m.name], [key]: value },
686
+ }))
687
+ }
688
+ />
689
+ ) : null}
690
+ {errors[m.name] ? (
691
+ <div className="mt-1">
692
+ <ErrorLine error={errors[m.name]} />
693
+ </div>
694
+ ) : null}
695
+ {inlineResults && results[m.name] !== undefined ? (
696
+ <div className="mt-1">
697
+ <JsonView data={results[m.name]} />
698
+ </div>
699
+ ) : null}
700
+ </div>
701
+ );
702
+ })}
703
+ </div>
704
+ ) : null}
705
+ </div>
706
+ );
707
+ }
708
+
709
+ // ---------------------------------------------------------------------------
710
+ // Structured argument inputs — one labeled field per declared parameter.
711
+ // Values are smart-parsed on invoke (JSON when it parses, plain string
712
+ // otherwise), so simple strings need no quotes.
713
+ // ---------------------------------------------------------------------------
714
+
715
+ function MethodArgInputs({
716
+ fields,
717
+ values,
718
+ onChange,
719
+ }: {
720
+ fields: readonly ParamField[];
721
+ values: Readonly<Record<string, string>>;
722
+ onChange: (key: string, value: string) => void;
723
+ }) {
724
+ return (
725
+ <div className="mt-1 flex flex-col gap-1">
726
+ {fields.map((f, i) => {
727
+ if (f.kind === 'object') {
728
+ return (
729
+ <div key={i}>
730
+ <div className="mb-0.5 font-mono text-[10px] text-neutral-600">{f.name}</div>
731
+ <div className="flex flex-col gap-1 border-l border-neutral-800 pl-2">
732
+ {f.keys.map((key) => (
733
+ <div key={key} className="flex items-center gap-1.5">
734
+ <ArgLabel label={key} />
735
+ <ArgInput
736
+ placeholder="JSON or plain string"
737
+ value={values[fieldKey(i, key)] ?? ''}
738
+ onChange={(v) => onChange(fieldKey(i, key), v)}
739
+ />
740
+ </div>
741
+ ))}
742
+ </div>
743
+ </div>
744
+ );
745
+ }
746
+ if (f.kind === 'value') {
747
+ return (
748
+ <div key={i} className="flex items-center gap-1.5">
749
+ <ArgLabel label={f.name} />
750
+ <ArgInput
751
+ placeholder={
752
+ f.defaultValue !== undefined
753
+ ? `default: ${f.defaultValue}`
754
+ : 'JSON or plain string'
755
+ }
756
+ value={values[fieldKey(i)] ?? ''}
757
+ onChange={(v) => onChange(fieldKey(i), v)}
758
+ />
759
+ </div>
760
+ );
761
+ }
762
+ return (
763
+ <div key={i} className="flex items-center gap-1.5">
764
+ <ArgLabel label={f.label} />
765
+ <ArgInput
766
+ placeholder="arg (JSON)"
767
+ value={values[fieldKey(i)] ?? ''}
768
+ onChange={(v) => onChange(fieldKey(i), v)}
769
+ />
770
+ </div>
771
+ );
772
+ })}
773
+ </div>
774
+ );
775
+ }
776
+
777
+ function ArgLabel({ label }: { label: string }) {
778
+ return (
779
+ <span
780
+ className="w-28 shrink-0 truncate text-right font-mono text-[10px] text-neutral-500"
781
+ title={label}
782
+ >
783
+ {label}
784
+ </span>
785
+ );
786
+ }
787
+
788
+ function ArgInput({
789
+ placeholder,
790
+ value,
791
+ onChange,
792
+ }: {
793
+ placeholder: string;
794
+ value: string;
795
+ onChange: (value: string) => void;
796
+ }) {
797
+ return (
798
+ <input
799
+ className="min-w-0 flex-1 rounded border border-neutral-700 bg-neutral-950 px-2 py-1 font-mono text-[11px] text-neutral-100 outline-none focus:border-sky-600"
800
+ placeholder={placeholder}
801
+ value={value}
802
+ onChange={(e) => onChange(e.target.value)}
803
+ />
804
+ );
805
+ }
apps/kimi-inspect/src/components/SessionPane.tsx ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Session pane — everything session-scoped: the pending-interactions card
3
+ * and the session Service panels under the `Services` tab, plus a `State`
4
+ * tab reading the session's registered plain-data state through
5
+ * `ISessionStateService.snapshot()` (every key a Session Service registered
6
+ * into the session-state container, JSON-safe). Rendered as the `Session`
7
+ * tab of the chat view's right dock (`RightPanel`). The Service panels are
8
+ * fetch-on-demand (no Service-event push channel exists); the State tab
9
+ * instead auto-loads on mount and polls once a second, so it stays live
10
+ * without a Refresh button.
11
+ */
12
+
13
+ import { ISessionStateService } from '@moonshot-ai/agent-core-v2/session/state/sessionState';
14
+ import { useQuery } from '@tanstack/react-query';
15
+ import { useMemo, useState } from 'react';
16
+
17
+ import { serviceByName } from '../channel';
18
+ import { useConnection } from '../connection';
19
+ import { type AnyService } from '../panels';
20
+ import { fetchSessionWorkspaceAssociation } from '../snapshots/api';
21
+ import { InteractionsCard } from './InteractionsCard';
22
+ import { ScopePanels } from './ServicePanels';
23
+ import { StateCard } from './StateCard';
24
+
25
+ type Tab = 'services' | 'state';
26
+
27
+ export function SessionPane({ sessionId, ready }: { sessionId: string | null; ready: boolean }) {
28
+ const { klient } = useConnection();
29
+ const [tab, setTab] = useState<Tab>('services');
30
+ const association = useQuery({
31
+ queryKey: ['session-workspace-association', klient.baseUrl, sessionId],
32
+ queryFn: () => fetchSessionWorkspaceAssociation(klient, sessionId as string),
33
+ enabled: sessionId !== null && ready,
34
+ refetchInterval: 1_000,
35
+ });
36
+
37
+ const proxyFor = useMemo(() => {
38
+ return (name: string): AnyService | null => {
39
+ return (
40
+ serviceByName<AnyService>(klient, name, {
41
+ scope: 'session',
42
+ sessionId: sessionId !== null && ready ? sessionId : undefined,
43
+ }) ?? null
44
+ );
45
+ };
46
+ }, [klient, sessionId, ready]);
47
+
48
+ const blocked = sessionId === null || !ready;
49
+
50
+ return (
51
+ <div className="flex h-full min-h-0 flex-1 flex-col">
52
+ <div className="flex border-b border-neutral-800 text-[11px]">
53
+ {(['services', 'state'] as const).map((t) => (
54
+ <button
55
+ key={t}
56
+ className={`flex-1 px-2 py-2 font-medium uppercase tracking-wider ${
57
+ tab === t ? 'bg-neutral-800 text-sky-400' : 'text-neutral-500 hover:text-neutral-300'
58
+ }`}
59
+ onClick={() => setTab(t)}
60
+ >
61
+ {t === 'services' ? 'Services' : 'State'}
62
+ </button>
63
+ ))}
64
+ </div>
65
+ <div className="flex-1 overflow-y-auto p-3">
66
+ {blocked ? (
67
+ <div className="text-[12px] text-neutral-600">
68
+ {sessionId === null ? 'No session selected.' : 'Loading session…'}
69
+ </div>
70
+ ) : (
71
+ <>
72
+ <div className="mb-3 rounded border border-neutral-800 bg-neutral-950/40 p-2 text-[11px]">
73
+ <div className="mb-1 font-semibold uppercase tracking-wider text-neutral-500">Workspace association</div>
74
+ <div className="grid grid-cols-[80px_minmax(0,1fr)] gap-1 font-mono">
75
+ <span className="text-neutral-600">workspace</span>
76
+ <span className="break-all text-neutral-300">{association.data?.workspaceId ?? 'loading…'}</span>
77
+ <span className="text-neutral-600">cwd</span>
78
+ <span className="break-all text-neutral-300">{association.data?.cwd ?? 'loading…'}</span>
79
+ </div>
80
+ </div>
81
+ {tab === 'services' ? (
82
+ <>
83
+ <InteractionsCard sessionId={sessionId} />
84
+ <ScopePanels scope="session" proxyFor={proxyFor} />
85
+ </>
86
+ ) : (
87
+ <StateCard
88
+ id={sessionId}
89
+ queryKey={['sessionState', sessionId]}
90
+ title="Session state"
91
+ label="sessionStateService"
92
+ fetchSnapshot={() => klient.session(sessionId).service(ISessionStateService).snapshot()}
93
+ />
94
+ )}
95
+ </>
96
+ )}
97
+ </div>
98
+ </div>
99
+ );
100
+ }
apps/kimi-inspect/src/components/Sidebar.tsx ADDED
@@ -0,0 +1,602 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Left sidebar — a single-column workspace → session tree backed by the v2
3
+ * list's grouped projection (`GET /api/v2/sessions?view=by_workspace`, see
4
+ * `src/sessions/api.ts`): one request returns every workspace with a matching
5
+ * session, each carrying its first `group.page_size` sessions under the
6
+ * requested sort plus the workspace's full matching total. Preset views
7
+ * (`src/sessions/views.ts`) map onto the endpoint's status / archived / git
8
+ * query conditions; the active view, collapsed workspaces, and the panel
9
+ * width persist to localStorage. Group pagination is the endpoint's opaque
10
+ * cursor (Load more), with a slow poll on top; a workspace whose total
11
+ * outruns the served slice expands inline into the flat per-workspace
12
+ * listing. Live activity frames (`useSessionActivities`) override the REST
13
+ * status badge. Session creation still goes through the v1 REST endpoint.
14
+ */
15
+
16
+ import { IAgentProfileService } from '@moonshot-ai/agent-core-v2/agent/profile/profile';
17
+ import { IConfigService } from '@moonshot-ai/agent-core-v2/app/config/config';
18
+ import { ISessionManager } from '@moonshot-ai/agent-core-v2/app/sessionManager/sessionManager';
19
+ import {
20
+ IWorkspaceService,
21
+ type Workspace,
22
+ } from '@moonshot-ai/agent-core-v2/app/workspace/workspace';
23
+ import { IModelCatalog } from '@moonshot-ai/agent-core-v2/llm-adapter/model/catalog';
24
+ import { useInfiniteQuery, useQuery, useQueryClient } from '@tanstack/react-query';
25
+ import { useMemo, useState } from 'react';
26
+
27
+ import type { SessionWorkFacts } from '../activity/store';
28
+ import { useSessionActivities } from '../activity/useSessionActivity';
29
+ import type { InspectClient } from '../channel';
30
+ import { useConnection } from '../connection';
31
+ import {
32
+ fetchV2SessionGroups,
33
+ fetchV2SessionsPage,
34
+ type V2ActivityStatus,
35
+ type V2Session,
36
+ type V2SessionGroup,
37
+ type V2SessionSort,
38
+ } from '../sessions/api';
39
+ import { SESSION_VIEWS, sessionViewById, type SessionView } from '../sessions/views';
40
+ import { Badge, ErrorLine, relTime } from '../ui';
41
+
42
+ const STORAGE_KEY = 'kimi-inspect.session-table';
43
+
44
+ const DEFAULT_WIDTH = 320;
45
+ const MIN_WIDTH = 240;
46
+ const MAX_WIDTH = 640;
47
+ const GROUP_PAGE_SIZE = 50;
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // Persisted panel prefs
51
+ // ---------------------------------------------------------------------------
52
+
53
+ interface PanelPrefs {
54
+ readonly view?: string;
55
+ /** Collapsed workspace ids; absent = everything expanded. */
56
+ readonly collapsed?: readonly string[];
57
+ readonly width?: number;
58
+ }
59
+
60
+ function readPrefs(): PanelPrefs {
61
+ try {
62
+ const raw = localStorage.getItem(STORAGE_KEY);
63
+ if (raw !== null) return JSON.parse(raw) as PanelPrefs;
64
+ } catch {
65
+ // corrupt storage — fall through to defaults
66
+ }
67
+ return {};
68
+ }
69
+
70
+ function writePrefs(prefs: PanelPrefs): void {
71
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs));
72
+ }
73
+
74
+ // ---------------------------------------------------------------------------
75
+ // Live status override (mirrors the server's mapActivityStatus)
76
+ // ---------------------------------------------------------------------------
77
+
78
+ function liveStatus(facts: SessionWorkFacts | undefined): V2ActivityStatus | undefined {
79
+ if (facts === undefined) return undefined;
80
+ if (facts.pendingInteraction === 'approval') return 'approval';
81
+ if (facts.pendingInteraction === 'question') return 'question';
82
+ if (facts.busy || facts.mainTurnActive) return 'running';
83
+ if (facts.lastTurnReason === 'failed') return 'failed';
84
+ return 'idle';
85
+ }
86
+
87
+ const STATUS_TONES: Record<V2ActivityStatus, 'green' | 'amber' | 'sky' | 'red' | 'neutral'> = {
88
+ running: 'green',
89
+ approval: 'amber',
90
+ question: 'sky',
91
+ failed: 'red',
92
+ idle: 'neutral',
93
+ };
94
+
95
+ const SORTS: readonly { readonly id: V2SessionSort; readonly label: string }[] = [
96
+ { id: 'meta.updated_at_desc', label: 'Updated ↓' },
97
+ { id: 'meta.updated_at_asc', label: 'Updated ↑' },
98
+ { id: 'meta.created_at_desc', label: 'Created ↓' },
99
+ ];
100
+
101
+ /**
102
+ * Default model for a fresh session: the configured global `defaultModel`
103
+ * first (the same fallback the profile bind uses), then the first connected
104
+ * provider's `default_model`. `undefined` means the server has nothing to
105
+ * offer — the session stays model-less and the chat surfaces
106
+ * `model.not_configured` as before.
107
+ */
108
+ async function resolveDefaultModel(klient: InspectClient): Promise<string | undefined> {
109
+ const configured: unknown = await klient.core(IConfigService).get('defaultModel');
110
+ if (typeof configured === 'string' && configured !== '') return configured;
111
+ const providers = await klient.core(IModelCatalog).listProviders();
112
+ const withDefault = providers.filter((p) => p.default_model !== undefined);
113
+ return (withDefault.find((p) => p.status === 'connected') ?? withDefault[0])?.default_model;
114
+ }
115
+
116
+ // ---------------------------------------------------------------------------
117
+ // Sidebar
118
+ // ---------------------------------------------------------------------------
119
+
120
+ export function Sidebar({
121
+ activeSessionId,
122
+ onSelectSession,
123
+ }: {
124
+ activeSessionId: string | null;
125
+ onSelectSession: (sessionId: string) => void;
126
+ }) {
127
+ const { klient, baseUrl, config } = useConnection();
128
+ const queryClient = useQueryClient();
129
+ const activities = useSessionActivities();
130
+
131
+ const [prefs, setPrefs] = useState<PanelPrefs>(readPrefs);
132
+ const view = sessionViewById(prefs.view);
133
+ const [sort, setSort] = useState<V2SessionSort>('meta.updated_at_desc');
134
+ const width = prefs.width ?? DEFAULT_WIDTH;
135
+ const collapsed = useMemo(() => new Set(prefs.collapsed ?? []), [prefs.collapsed]);
136
+
137
+ const updatePrefs = (patch: PanelPrefs) => {
138
+ setPrefs((prev) => {
139
+ const next: PanelPrefs = { ...prev, ...patch };
140
+ writePrefs(next);
141
+ return next;
142
+ });
143
+ };
144
+
145
+ const toggleCollapsed = (workspaceId: string) => {
146
+ const next = new Set(collapsed);
147
+ if (next.has(workspaceId)) next.delete(workspaceId);
148
+ else next.add(workspaceId);
149
+ updatePrefs({ collapsed: [...next] });
150
+ };
151
+
152
+ const token = config.token.trim();
153
+ const authToken = token === '' ? undefined : token;
154
+
155
+ const workspaces = useQuery({
156
+ queryKey: ['workspaces'],
157
+ queryFn: () => klient.core(IWorkspaceService).list(),
158
+ refetchInterval: 15_000,
159
+ });
160
+ const workspaceNames = useMemo(
161
+ () => new Map((workspaces.data ?? []).map((ws) => [ws.id, ws.name] as const)),
162
+ [workspaces.data],
163
+ );
164
+
165
+ const groups = useInfiniteQuery({
166
+ queryKey: ['v2-sessions', 'tree', view.id, sort],
167
+ queryFn: ({ pageParam }) =>
168
+ fetchV2SessionGroups({
169
+ baseUrl,
170
+ token: authToken,
171
+ statuses: view.statuses,
172
+ archived: view.archived,
173
+ includeGit: view.includeGit,
174
+ sort,
175
+ pageSize: 50,
176
+ groupPageSize: GROUP_PAGE_SIZE,
177
+ pageToken: pageParam,
178
+ }),
179
+ initialPageParam: undefined as string | undefined,
180
+ getNextPageParam: (last) => last.nextPageToken,
181
+ refetchInterval: 15_000,
182
+ });
183
+
184
+ const groupList = useMemo(
185
+ () => groups.data?.pages.flatMap((page) => page.groups) ?? [],
186
+ [groups.data],
187
+ );
188
+
189
+ const createSession = async (ws: Workspace | null) => {
190
+ // With a workspace, the server derives workDir from workspace.root, so no cwd is needed.
191
+ let body: string;
192
+ if (ws !== null) {
193
+ body = JSON.stringify({ workspace_id: ws.id });
194
+ } else {
195
+ const cwd = window.prompt('Working directory for the new session:', '');
196
+ if (cwd === null || cwd.trim() === '') return;
197
+ body = JSON.stringify({ metadata: { cwd: cwd.trim() } });
198
+ }
199
+ const headers: Record<string, string> = { 'content-type': 'application/json' };
200
+ if (token !== '') headers['authorization'] = `Bearer ${token}`;
201
+ const res = await fetch(`${baseUrl}/api/v1/sessions`, {
202
+ method: 'POST',
203
+ headers,
204
+ body,
205
+ });
206
+ const envelope = (await res.json()) as { code: number; msg: string; data: { id: string } };
207
+ if (envelope.code !== 0) {
208
+ window.alert(`create session failed: ${envelope.msg}`);
209
+ return;
210
+ }
211
+ const sessionId = envelope.data.id;
212
+ // The REST create route ignores agent_config, so bind the default model
213
+ // over the channel — the same resume + setModel path the Model Catalog's
214
+ // "+ Session" button uses. Best-effort: a failure leaves the session
215
+ // model-less instead of blocking the creation flow.
216
+ try {
217
+ const model = await resolveDefaultModel(klient);
218
+ if (model !== undefined) {
219
+ await klient.core(ISessionManager).resume(sessionId);
220
+ await klient.session(sessionId).agent('main').service(IAgentProfileService).setModel(model);
221
+ }
222
+ } catch (error) {
223
+ console.warn('failed to set the default model on the new session', error);
224
+ }
225
+ await queryClient.invalidateQueries({ queryKey: ['v2-sessions'] });
226
+ onSelectSession(sessionId);
227
+ };
228
+
229
+ const cycleSort = () => {
230
+ const index = SORTS.findIndex((s) => s.id === sort);
231
+ setSort(SORTS[(index + 1) % SORTS.length]!.id);
232
+ };
233
+
234
+ const startResize = (e: React.MouseEvent) => {
235
+ e.preventDefault();
236
+ const startX = e.clientX;
237
+ const startWidth = width;
238
+ const onMove = (ev: MouseEvent) => {
239
+ const next = Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, startWidth + ev.clientX - startX));
240
+ setPrefs((prev) => ({ ...prev, width: next }));
241
+ };
242
+ const onUp = (ev: MouseEvent) => {
243
+ window.removeEventListener('mousemove', onMove);
244
+ window.removeEventListener('mouseup', onUp);
245
+ const next = Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, startWidth + ev.clientX - startX));
246
+ updatePrefs({ width: next });
247
+ };
248
+ window.addEventListener('mousemove', onMove);
249
+ window.addEventListener('mouseup', onUp);
250
+ };
251
+
252
+ return (
253
+ <div
254
+ className="relative flex h-full shrink-0 flex-col border-r border-neutral-800"
255
+ style={{ width }}
256
+ >
257
+ {/* View tabs */}
258
+ <div className="flex items-center gap-1 overflow-x-auto border-b border-neutral-800 px-2 py-1.5">
259
+ {SESSION_VIEWS.map((v) => (
260
+ <button
261
+ key={v.id}
262
+ className={`shrink-0 rounded px-2 py-0.5 text-[11px] ${
263
+ v.id === view.id
264
+ ? 'bg-neutral-800 font-medium text-neutral-100'
265
+ : 'text-neutral-500 hover:text-neutral-300'
266
+ }`}
267
+ onClick={() => updatePrefs({ view: v.id })}
268
+ >
269
+ {v.label}
270
+ </button>
271
+ ))}
272
+ </div>
273
+
274
+ {/* Toolbar: new session + sort */}
275
+ <div className="flex items-center justify-between border-b border-neutral-800 px-2 py-1">
276
+ <NewSessionMenu workspaces={workspaces.data ?? []} onCreate={createSession} />
277
+ <button
278
+ className="rounded border border-neutral-700 px-2 py-0.5 text-[11px] text-neutral-400 hover:bg-neutral-800"
279
+ title="Click to change sort"
280
+ onClick={cycleSort}
281
+ >
282
+ {SORTS.find((s) => s.id === sort)?.label}
283
+ </button>
284
+ </div>
285
+
286
+ {/* Tree body */}
287
+ <div className="flex-1 overflow-y-auto">
288
+ {groups.isError ? <ErrorLine error={groups.error} /> : null}
289
+ {groupList.map((group) => (
290
+ <WorkspaceNode
291
+ key={group.workspace.id}
292
+ group={group}
293
+ view={view}
294
+ sort={sort}
295
+ collapsed={collapsed.has(group.workspace.id)}
296
+ onToggleCollapsed={() => toggleCollapsed(group.workspace.id)}
297
+ workspaceNames={workspaceNames}
298
+ activeSessionId={activeSessionId}
299
+ activityOf={(id) => activities.get(id)}
300
+ onSelect={onSelectSession}
301
+ baseUrl={baseUrl}
302
+ token={authToken}
303
+ />
304
+ ))}
305
+ {groups.isLoading ? (
306
+ <div className="px-3 py-2 text-[11px] text-neutral-600">loading…</div>
307
+ ) : null}
308
+ {!groups.isLoading && groupList.length === 0 && !groups.isError ? (
309
+ <div className="px-3 py-2 text-[11px] text-neutral-600">no sessions</div>
310
+ ) : null}
311
+ {groups.hasNextPage ? (
312
+ <button
313
+ className="w-full border-t border-neutral-800 px-3 py-1.5 text-[11px] text-sky-500 hover:bg-neutral-800/60 hover:text-sky-400"
314
+ disabled={groups.isFetchingNextPage}
315
+ onClick={() => void groups.fetchNextPage()}
316
+ >
317
+ {groups.isFetchingNextPage ? 'loading…' : 'Load more workspaces'}
318
+ </button>
319
+ ) : null}
320
+ </div>
321
+
322
+ {/* Resize handle */}
323
+ <div
324
+ className="absolute top-0 right-0 h-full w-1 cursor-col-resize hover:bg-sky-700/50"
325
+ onMouseDown={startResize}
326
+ />
327
+ </div>
328
+ );
329
+ }
330
+
331
+ // ---------------------------------------------------------------------------
332
+ // Tree nodes
333
+ // ---------------------------------------------------------------------------
334
+
335
+ function WorkspaceNode({
336
+ group,
337
+ view,
338
+ sort,
339
+ collapsed,
340
+ onToggleCollapsed,
341
+ workspaceNames,
342
+ activeSessionId,
343
+ activityOf,
344
+ onSelect,
345
+ baseUrl,
346
+ token,
347
+ }: {
348
+ group: V2SessionGroup;
349
+ view: SessionView;
350
+ sort: V2SessionSort;
351
+ collapsed: boolean;
352
+ onToggleCollapsed: () => void;
353
+ workspaceNames: ReadonlyMap<string, string>;
354
+ activeSessionId: string | null;
355
+ activityOf: (sessionId: string) => SessionWorkFacts | undefined;
356
+ onSelect: (sessionId: string) => void;
357
+ baseUrl: string;
358
+ token?: string | undefined;
359
+ }) {
360
+ const [showAll, setShowAll] = useState(false);
361
+ const hasMore = group.total > group.sessions.length;
362
+ return (
363
+ <div>
364
+ <div
365
+ className="flex cursor-pointer items-center gap-1.5 border-b border-neutral-800 px-2 py-1.5 select-none hover:bg-neutral-800/60"
366
+ onClick={onToggleCollapsed}
367
+ title={group.workspace.cwd ?? group.workspace.id}
368
+ >
369
+ <span className="w-3 shrink-0 text-center text-[9px] text-neutral-600">
370
+ {collapsed ? '▸' : '▾'}
371
+ </span>
372
+ <span className="min-w-0 flex-1 truncate text-[11px] font-semibold text-neutral-300">
373
+ {workspaceNames.get(group.workspace.id) ?? group.workspace.cwd ?? group.workspace.id}
374
+ </span>
375
+ <span className="shrink-0 text-[10px] text-neutral-600">{group.total}</span>
376
+ </div>
377
+ {collapsed ? null : (
378
+ <>
379
+ {showAll ? null : (
380
+ <>
381
+ {group.sessions.map((s) => (
382
+ <SessionNode
383
+ key={s.id}
384
+ s={s}
385
+ active={s.id === activeSessionId}
386
+ activity={activityOf(s.id)}
387
+ onClick={() => onSelect(s.id)}
388
+ />
389
+ ))}
390
+ {hasMore ? (
391
+ <button
392
+ className="w-full border-b border-neutral-800/50 py-1 pl-6 text-left text-[10px] text-sky-500 hover:bg-neutral-800/60 hover:text-sky-400"
393
+ onClick={() => setShowAll(true)}
394
+ >
395
+ Show all {group.total}…
396
+ </button>
397
+ ) : null}
398
+ </>
399
+ )}
400
+ {showAll ? (
401
+ <FullGroupList
402
+ workspaceId={group.workspace.id}
403
+ view={view}
404
+ sort={sort}
405
+ baseUrl={baseUrl}
406
+ token={token}
407
+ activeSessionId={activeSessionId}
408
+ activityOf={activityOf}
409
+ onSelect={onSelect}
410
+ />
411
+ ) : null}
412
+ </>
413
+ )}
414
+ </div>
415
+ );
416
+ }
417
+
418
+ /**
419
+ * The flat per-workspace listing behind "Show all" — pages the ungrouped
420
+ * projection filtered to this workspace, so sessions beyond the grouped
421
+ * slice stay reachable.
422
+ */
423
+ function FullGroupList({
424
+ workspaceId,
425
+ view,
426
+ sort,
427
+ baseUrl,
428
+ token,
429
+ activeSessionId,
430
+ activityOf,
431
+ onSelect,
432
+ }: {
433
+ workspaceId: string;
434
+ view: SessionView;
435
+ sort: V2SessionSort;
436
+ baseUrl: string;
437
+ token?: string | undefined;
438
+ activeSessionId: string | null;
439
+ activityOf: (sessionId: string) => SessionWorkFacts | undefined;
440
+ onSelect: (sessionId: string) => void;
441
+ }) {
442
+ const sessions = useInfiniteQuery({
443
+ queryKey: ['v2-sessions', 'tree-full', workspaceId, view.id, sort],
444
+ queryFn: ({ pageParam }) =>
445
+ fetchV2SessionsPage({
446
+ baseUrl,
447
+ token,
448
+ workspaceIds: [workspaceId],
449
+ statuses: view.statuses,
450
+ archived: view.archived,
451
+ includeGit: view.includeGit,
452
+ sort,
453
+ pageSize: 50,
454
+ pageToken: pageParam,
455
+ }),
456
+ initialPageParam: undefined as string | undefined,
457
+ getNextPageParam: (last) => last.nextPageToken,
458
+ });
459
+ const seen = new Set<string>();
460
+ const items = (sessions.data?.pages.flatMap((page) => page.items) ?? []).filter((s) => {
461
+ if (seen.has(s.id)) return false;
462
+ seen.add(s.id);
463
+ return true;
464
+ });
465
+ return (
466
+ <div className="bg-neutral-950/40">
467
+ {items.map((s) => (
468
+ <SessionNode
469
+ key={s.id}
470
+ s={s}
471
+ active={s.id === activeSessionId}
472
+ activity={activityOf(s.id)}
473
+ onClick={() => onSelect(s.id)}
474
+ />
475
+ ))}
476
+ {sessions.isLoading ? (
477
+ <div className="py-1 pl-6 text-[10px] text-neutral-600">loading…</div>
478
+ ) : null}
479
+ {sessions.hasNextPage ? (
480
+ <button
481
+ className="w-full py-1 pl-6 text-left text-[10px] text-sky-500 hover:bg-neutral-800/60 hover:text-sky-400"
482
+ disabled={sessions.isFetchingNextPage}
483
+ onClick={() => void sessions.fetchNextPage()}
484
+ >
485
+ {sessions.isFetchingNextPage ? 'loading…' : 'Load more'}
486
+ </button>
487
+ ) : null}
488
+ </div>
489
+ );
490
+ }
491
+
492
+ function SessionNode({
493
+ s,
494
+ active,
495
+ activity,
496
+ onClick,
497
+ }: {
498
+ s: V2Session;
499
+ active: boolean;
500
+ activity?: SessionWorkFacts | undefined;
501
+ onClick: () => void;
502
+ }) {
503
+ const status = liveStatus(activity) ?? s.activity.status;
504
+ return (
505
+ <div
506
+ className={`cursor-pointer border-b border-neutral-800/50 py-1 pr-2 pl-6 hover:bg-neutral-800/60 ${
507
+ active ? 'bg-sky-950/60' : ''
508
+ }`}
509
+ onClick={onClick}
510
+ >
511
+ <div className="flex items-center gap-1.5">
512
+ {status === 'idle' ? null : <Badge tone={STATUS_TONES[status]}>{status}</Badge>}
513
+ <span className="min-w-0 flex-1 truncate text-[12px] text-neutral-200">
514
+ {s.meta.title ?? s.meta.lastPrompt ?? s.id}
515
+ </span>
516
+ {s.meta.archived ? <Badge tone="neutral">archived</Badge> : null}
517
+ <span className="shrink-0 text-[10px] text-neutral-500">{relTime(s.meta.updatedAt)}</span>
518
+ </div>
519
+ <div className="flex items-center gap-2 truncate font-mono text-[10px] text-neutral-600">
520
+ <span className="truncate">{s.id.slice(0, 12)}</span>
521
+ {s.git !== undefined && s.git.branch !== null ? (
522
+ <span className="truncate" title={s.git.branch}>
523
+ {s.git.branch}
524
+ </span>
525
+ ) : null}
526
+ {s.git !== undefined && s.git.pullRequest !== null ? (
527
+ <a
528
+ className="shrink-0 text-sky-500 hover:text-sky-400"
529
+ href={s.git.pullRequest.url}
530
+ target="_blank"
531
+ rel="noreferrer"
532
+ onClick={(e) => e.stopPropagation()}
533
+ >
534
+ #{s.git.pullRequest.number}
535
+ </a>
536
+ ) : null}
537
+ </div>
538
+ </div>
539
+ );
540
+ }
541
+
542
+ // ---------------------------------------------------------------------------
543
+ // Toolbar dropdowns
544
+ // ---------------------------------------------------------------------------
545
+
546
+ function useDropdown(): { open: boolean; toggle: () => void; close: () => void } {
547
+ const [open, setOpen] = useState(false);
548
+ return {
549
+ open,
550
+ toggle: () => setOpen((v) => !v),
551
+ close: () => setOpen(false),
552
+ };
553
+ }
554
+
555
+ function NewSessionMenu({
556
+ workspaces,
557
+ onCreate,
558
+ }: {
559
+ workspaces: readonly Workspace[];
560
+ onCreate: (ws: Workspace | null) => Promise<void>;
561
+ }) {
562
+ const { open, toggle, close } = useDropdown();
563
+ return (
564
+ <div className="relative">
565
+ <button
566
+ className="rounded border border-neutral-700 px-2 py-0.5 text-[11px] text-neutral-300 hover:bg-neutral-800"
567
+ onClick={toggle}
568
+ >
569
+ + New session
570
+ </button>
571
+ {open ? (
572
+ <>
573
+ <div className="fixed inset-0 z-10" onClick={close} />
574
+ <div className="absolute left-0 z-20 mt-1 w-64 rounded border border-neutral-700 bg-neutral-900 py-1 shadow-xl">
575
+ {workspaces.map((ws) => (
576
+ <button
577
+ key={ws.id}
578
+ className="block w-full truncate px-3 py-1.5 text-left text-[11px] text-neutral-200 hover:bg-neutral-800"
579
+ title={ws.root}
580
+ onClick={() => {
581
+ close();
582
+ void onCreate(ws);
583
+ }}
584
+ >
585
+ {ws.name}
586
+ </button>
587
+ ))}
588
+ <button
589
+ className="block w-full border-t border-neutral-800 px-3 py-1.5 text-left text-[11px] text-neutral-400 hover:bg-neutral-800"
590
+ onClick={() => {
591
+ close();
592
+ void onCreate(null);
593
+ }}
594
+ >
595
+ No workspace (prompt for cwd)…
596
+ </button>
597
+ </div>
598
+ </>
599
+ ) : null}
600
+ </div>
601
+ );
602
+ }
apps/kimi-inspect/src/components/StateCard.tsx ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * State card — one scope's registered state as a live diff tree, shared by
3
+ * the session `State` tab (`SessionPane`) and the agent `State` tab
4
+ * (`RightPanel`). Same view as the audit panel's "Diff vs prev" (`StateTree`
5
+ * over a `DiffNode` root), so every key is an interactive, collapsible row.
6
+ * Auto-loads on mount and polls once a second: the first snapshot (and any
7
+ * owner switch) renders as a plain tree, each later snapshot folds in as a
8
+ * structural diff against the previous one (added = green, removed = red,
9
+ * modified = amber). Changed subtrees auto-open; rows the user expanded stay
10
+ * expanded across polls.
11
+ */
12
+
13
+ import { useQuery } from '@tanstack/react-query';
14
+ import { useEffect, useState, type ReactNode } from 'react';
15
+
16
+ import { diffValue, type DiffNode } from '../audit/diff';
17
+ import { ActionButton, Badge, ErrorLine } from '../ui';
18
+ import { StateTree, plainNode } from './audit/StateTree';
19
+
20
+ export function StateCard({
21
+ id,
22
+ queryKey,
23
+ title,
24
+ label,
25
+ badge,
26
+ fetchSnapshot,
27
+ }: {
28
+ /** Identity of the state owner (`sessionId`, or `sessionId/agentId`); a
29
+ * change resets the diff baseline so a fresh owner renders as a plain tree
30
+ * instead of an all-green diff against the previous owner. */
31
+ id: string;
32
+ queryKey: readonly unknown[];
33
+ /** Card title, e.g. `Session state` / `Agent state`. */
34
+ title: string;
35
+ /** Mono caption next to the title: the state service's channel name. */
36
+ label: string;
37
+ badge?: ReactNode;
38
+ fetchSnapshot: () => Promise<Record<string, unknown>>;
39
+ }) {
40
+ const query = useQuery({
41
+ queryKey,
42
+ queryFn: async () => ({ id, snapshot: await fetchSnapshot() }),
43
+ refetchInterval: 1000,
44
+ placeholderData: (previous) => previous,
45
+ });
46
+
47
+ // Fold each poll into a diff against the previous snapshot. `plainNode`
48
+ // on first load / owner switch — diffing a freshly loaded tree against
49
+ // nothing (or against the previous owner) would paint it all-green. While
50
+ // the new owner's fetch is in flight the query still carries the previous
51
+ // owner's placeholder data (`data.id !== id`); ignore it so a stale tree
52
+ // never renders under the new owner's badge.
53
+ const [tree, setTree] = useState<{ id: string; node: DiffNode } | null>(null);
54
+ useEffect(() => {
55
+ const data = query.data;
56
+ if (data === undefined || data.id !== id) return;
57
+ setTree((prev) =>
58
+ prev === null || prev.id !== data.id
59
+ ? { id: data.id, node: plainNode(data.snapshot) }
60
+ : { id: data.id, node: diffValue(prev.node.value, data.snapshot) },
61
+ );
62
+ }, [query.data, id]);
63
+
64
+ // The tree is only shown when it belongs to the current owner — after an
65
+ // owner switch the old tree is suppressed until the new snapshot arrives.
66
+ const visibleTree = tree !== null && tree.id === id ? tree : null;
67
+
68
+ // One-click expand/collapse: bumping `nonce` remounts the tree, and the
69
+ // fresh `defaultDepth` decides how deep nodes open (Infinity = expand all,
70
+ // 0 = collapse all — diff-changed subtrees still auto-open, as in the
71
+ // audit panel). Polls afterwards keep the local open state untouched.
72
+ const [expand, setExpand] = useState({ nonce: 0, depth: 1 });
73
+
74
+ return (
75
+ <div className="mb-3 rounded-lg border border-neutral-800 bg-neutral-900/60">
76
+ <div className="flex items-center justify-between border-b border-neutral-800/60 px-3 py-2">
77
+ <div>
78
+ <span className="text-[12px] font-medium text-neutral-200">{title}</span>
79
+ <span className="ml-2 font-mono text-[10px] text-neutral-600">{label}</span>
80
+ {badge}
81
+ {visibleTree !== null ? (
82
+ <Badge tone="neutral">
83
+ {Object.keys(visibleTree.node.value as Record<string, unknown>).length} keys
84
+ </Badge>
85
+ ) : null}
86
+ </div>
87
+ <div className="flex items-center gap-1.5">
88
+ <ActionButton
89
+ onClick={() =>
90
+ setExpand((s) => ({ nonce: s.nonce + 1, depth: Number.POSITIVE_INFINITY }))
91
+ }
92
+ >
93
+ Expand
94
+ </ActionButton>
95
+ <ActionButton onClick={() => setExpand((s) => ({ nonce: s.nonce + 1, depth: 0 }))}>
96
+ Collapse
97
+ </ActionButton>
98
+ <span className="text-[10px] text-neutral-600">live · 1s</span>
99
+ </div>
100
+ </div>
101
+ <div className="px-3 py-2">
102
+ {query.isError ? (
103
+ <div className="mb-2">
104
+ <ErrorLine error={query.error} />
105
+ </div>
106
+ ) : null}
107
+ {visibleTree === null ? (
108
+ <div className="text-[11px] text-neutral-600 italic">
109
+ {query.isPending || tree !== null ? 'Loading state…' : 'no state registered'}
110
+ </div>
111
+ ) : (
112
+ <StateTree key={expand.nonce} root={visibleTree.node} defaultDepth={expand.depth} />
113
+ )}
114
+ </div>
115
+ </div>
116
+ );
117
+ }
apps/kimi-inspect/src/components/WorkspaceDirBrowser.tsx ADDED
@@ -0,0 +1,211 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * WorkspaceDirBrowser — server-side filesystem browser living in the Workspace
3
+ * Services view's left sidebar, replacing the old <select> of registered
4
+ * workspaces. Directories are listed through the App-scope
5
+ * `IHostFolderBrowser` (home + recent roots + one-level directory listing);
6
+ * entries that are registered workspaces carry a `workspace` badge plus their
7
+ * trust state (`trusted` / `untrusted`, read through the Workspace-scope
8
+ * `IWorkspaceTrust`; servers predating that service simply show no trust
9
+ * badge). "Select" registers the current folder on demand —
10
+ * `IWorkspaceService.createOrTouch` is idempotent on root — before handing
11
+ * the workspace to the parent.
12
+ */
13
+
14
+ import {
15
+ IHostFolderBrowser,
16
+ type FsBrowseEntry,
17
+ } from '@moonshot-ai/agent-core-v2/app/hostFolderBrowser/hostFolderBrowser';
18
+ import {
19
+ IWorkspaceService,
20
+ type Workspace,
21
+ } from '@moonshot-ai/agent-core-v2/app/workspace/workspace';
22
+ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
23
+ import { useState } from 'react';
24
+
25
+ import type { InspectClient } from '../channel';
26
+ import { fetchWorkspaceSnapshot } from '../snapshots/api';
27
+ import { ErrorLine } from '../ui';
28
+
29
+ function normalizePath(path: string): string {
30
+ return path.replaceAll('\\', '/').replace(/\/+$/, '');
31
+ }
32
+
33
+ function baseName(path: string): string {
34
+ const normalized = normalizePath(path);
35
+ return normalized.split('/').pop() ?? normalized;
36
+ }
37
+
38
+ export function WorkspaceDirBrowser(props: {
39
+ klient: InspectClient;
40
+ workspaces: readonly Workspace[] | undefined;
41
+ onSelect: (workspace: Workspace) => void;
42
+ }) {
43
+ const { klient, workspaces, onSelect } = props;
44
+ const queryClient = useQueryClient();
45
+ /** Current browsed directory; null = the server default ($HOME). */
46
+ const [path, setPath] = useState<string | null>(null);
47
+
48
+ const home = useQuery({
49
+ queryKey: ['fs-home', klient.baseUrl],
50
+ queryFn: () => klient.core(IHostFolderBrowser).home(),
51
+ });
52
+ const browse = useQuery({
53
+ queryKey: ['fs-browse', klient.baseUrl, path],
54
+ queryFn: () =>
55
+ path === null
56
+ ? klient.core(IHostFolderBrowser).browse()
57
+ : klient.core(IHostFolderBrowser).browse(path),
58
+ });
59
+
60
+ const select = useMutation({
61
+ mutationFn: (root: string) => klient.core(IWorkspaceService).createOrTouch(root),
62
+ onSuccess: async (workspace) => {
63
+ await queryClient.invalidateQueries({ queryKey: ['workspaces', klient.baseUrl] });
64
+ onSelect(workspace);
65
+ },
66
+ });
67
+
68
+ const workspaceList = workspaces ?? [];
69
+ /** normalized root → trusted; undefined when the server predates `workspaceTrust`. */
70
+ const trustByRoot = useQuery({
71
+ queryKey: ['workspace-trust', klient.baseUrl, workspaceList.map((ws) => ws.id).join(',')],
72
+ enabled: workspaceList.length > 0,
73
+ retry: false,
74
+ queryFn: async () => {
75
+ const entries = await Promise.all(
76
+ workspaceList.map(async (ws) => {
77
+ try {
78
+ const snapshot = await fetchWorkspaceSnapshot(klient, ws.id);
79
+ return [normalizePath(ws.root), snapshot.program.trusted] as const;
80
+ } catch {
81
+ return [normalizePath(ws.root), undefined] as const;
82
+ }
83
+ }),
84
+ );
85
+ return new Map(entries);
86
+ },
87
+ });
88
+
89
+ const workspaceRoots = new Set(workspaceList.map((ws) => normalizePath(ws.root)));
90
+ const current = browse.data ?? null;
91
+ const recents = home.data?.recent_roots ?? [];
92
+ const isCurrentWorkspace = current !== null && workspaceRoots.has(normalizePath(current.path));
93
+
94
+ const renderEntry = (entry: FsBrowseEntry) => {
95
+ const isWorkspace = workspaceRoots.has(normalizePath(entry.path));
96
+ const trusted = trustByRoot.data?.get(normalizePath(entry.path));
97
+ return (
98
+ <button
99
+ key={entry.path}
100
+ type="button"
101
+ title={entry.path}
102
+ onClick={() => {
103
+ setPath(entry.path);
104
+ }}
105
+ className="flex w-full items-center gap-2 px-3 py-1 text-left hover:bg-neutral-900"
106
+ >
107
+ <span className="min-w-0 flex-1 truncate font-mono text-[11px] text-neutral-200">
108
+ {entry.name}/
109
+ </span>
110
+ {isWorkspace && trusted !== undefined ? (
111
+ <span
112
+ className={`shrink-0 rounded border px-1 text-[9px] font-semibold uppercase tracking-wider ${
113
+ trusted
114
+ ? 'border-emerald-800 text-emerald-400'
115
+ : 'border-amber-800 text-amber-400'
116
+ }`}
117
+ >
118
+ {trusted ? 'trusted' : 'untrusted'}
119
+ </span>
120
+ ) : null}
121
+ {isWorkspace ? (
122
+ <span className="shrink-0 rounded border border-sky-800 px-1 text-[9px] font-semibold uppercase tracking-wider text-sky-400">
123
+ workspace
124
+ </span>
125
+ ) : null}
126
+ </button>
127
+ );
128
+ };
129
+
130
+ return (
131
+ <div className="flex min-h-0 flex-1 flex-col">
132
+ <div className="flex flex-col gap-1 border-b border-neutral-800 px-3 py-2">
133
+ <div className="flex items-center gap-1">
134
+ <button
135
+ type="button"
136
+ onClick={() => {
137
+ setPath(null);
138
+ }}
139
+ className="shrink-0 rounded border border-neutral-700 px-2 py-0.5 text-[10px] text-neutral-300 hover:border-sky-600"
140
+ >
141
+ home
142
+ </button>
143
+ <button
144
+ type="button"
145
+ disabled={!current?.parent}
146
+ onClick={() => {
147
+ if (current?.parent) setPath(current.parent);
148
+ }}
149
+ className="shrink-0 rounded border border-neutral-700 px-2 py-0.5 text-[10px] text-neutral-300 hover:border-sky-600 disabled:opacity-40"
150
+ >
151
+ up
152
+ </button>
153
+ <button
154
+ type="button"
155
+ disabled={current === null || select.isPending}
156
+ onClick={() => {
157
+ if (current !== null) select.mutate(current.path);
158
+ }}
159
+ className="ml-auto shrink-0 rounded border border-sky-700 px-2 py-0.5 text-[10px] font-semibold text-sky-300 hover:bg-sky-950 disabled:opacity-40"
160
+ >
161
+ {select.isPending ? 'selecting…' : isCurrentWorkspace ? 'select' : 'register & select'}
162
+ </button>
163
+ </div>
164
+ <span
165
+ title={current?.path}
166
+ className="truncate font-mono text-[10px] text-neutral-500"
167
+ >
168
+ {current?.path ?? '…'}
169
+ </span>
170
+ </div>
171
+ {recents.length > 0 ? (
172
+ <div className="flex flex-wrap items-center gap-1 border-b border-neutral-800 px-3 py-2">
173
+ <span className="text-[10px] font-semibold uppercase tracking-wider text-neutral-600">
174
+ recent
175
+ </span>
176
+ {recents.map((root) => (
177
+ <button
178
+ key={root}
179
+ type="button"
180
+ title={root}
181
+ onClick={() => {
182
+ setPath(root);
183
+ }}
184
+ className="max-w-32 truncate rounded border border-neutral-800 px-1.5 py-0.5 font-mono text-[10px] text-neutral-400 hover:border-sky-600 hover:text-neutral-200"
185
+ >
186
+ {baseName(root)}
187
+ </button>
188
+ ))}
189
+ </div>
190
+ ) : null}
191
+ <div className="min-h-0 flex-1 overflow-y-auto">
192
+ {browse.isError ? (
193
+ <div className="px-3 py-2">
194
+ <ErrorLine error={browse.error} />
195
+ </div>
196
+ ) : current === null ? (
197
+ <div className="px-3 py-2 text-[11px] text-neutral-600 italic">loading…</div>
198
+ ) : current.entries.length === 0 ? (
199
+ <div className="px-3 py-2 text-[11px] text-neutral-600 italic">no subdirectories</div>
200
+ ) : (
201
+ current.entries.map(renderEntry)
202
+ )}
203
+ </div>
204
+ {select.isError ? (
205
+ <div className="border-t border-neutral-800 px-3 py-1">
206
+ <ErrorLine error={select.error} />
207
+ </div>
208
+ ) : null}
209
+ </div>
210
+ );
211
+ }
apps/kimi-inspect/src/components/WorkspaceServicesView.tsx ADDED
@@ -0,0 +1,138 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Workspace Services view — the workspace-scope Service reflection as a
3
+ * standalone rail view. Same Postman-style three-pane layout
4
+ * (`ScopePanelsScrollspy`) as App Services, plus a left sidebar holding the
5
+ * workspace picker: a server-side directory browser (`WorkspaceDirBrowser`,
6
+ * over the App-scope `IHostFolderBrowser`) that marks already-registered
7
+ * workspaces and registers a picked folder on demand. The proxies resolve on
8
+ * the `/workspace/:id` route, so a workspace must be selected before any
9
+ * Service is callable. Picking one materializes its handler on demand
10
+ * server-side (`IWorkspaceLifecycleService.handlerFor` is create-or-get), no
11
+ * manual join needed.
12
+ */
13
+
14
+ import { IWorkspaceService } from '@moonshot-ai/agent-core-v2/app/workspace/workspace';
15
+ import { useQuery } from '@tanstack/react-query';
16
+ import { useEffect, useState, type ReactNode } from 'react';
17
+
18
+ import { useConnection } from '../connection';
19
+ import { fetchWorkspaceSnapshot } from '../snapshots/api';
20
+ import { Badge, ErrorLine } from '../ui';
21
+ import { WorkspaceDirBrowser } from './WorkspaceDirBrowser';
22
+
23
+ export function WorkspaceServicesView() {
24
+ const { klient, baseUrl } = useConnection();
25
+ const [workspaceId, setWorkspaceId] = useState<string | null>(null);
26
+
27
+ const workspaces = useQuery({
28
+ queryKey: ['workspaces', klient.baseUrl],
29
+ queryFn: () => klient.core(IWorkspaceService).list(),
30
+ });
31
+ const snapshot = useQuery({
32
+ queryKey: ['workspace-snapshot', klient.baseUrl, workspaceId],
33
+ queryFn: () => fetchWorkspaceSnapshot(klient, workspaceId as string),
34
+ enabled: workspaceId !== null,
35
+ refetchInterval: 1_000,
36
+ });
37
+
38
+ useEffect(() => {
39
+ setWorkspaceId(null);
40
+ }, [baseUrl]);
41
+
42
+ const selected = (workspaces.data ?? []).find((ws) => ws.id === workspaceId);
43
+ const data = snapshot.data;
44
+
45
+ return (
46
+ <div className="flex min-h-0 min-w-0 flex-1">
47
+ <aside className="flex w-72 shrink-0 flex-col border-r border-neutral-800">
48
+ <div className="border-b border-neutral-800 px-3 py-2">
49
+ <div className="text-[10px] font-semibold uppercase tracking-wider text-neutral-500">
50
+ Workspace
51
+ </div>
52
+ <div title={selected?.root} className="truncate font-mono text-[11px] text-neutral-300">
53
+ {selected === undefined ? (
54
+ <span className="text-neutral-600 italic">none selected</span>
55
+ ) : (
56
+ `${selected.name} — ${selected.root}`
57
+ )}
58
+ </div>
59
+ {workspaces.isError ? <ErrorLine error={workspaces.error} /> : null}
60
+ </div>
61
+ <WorkspaceDirBrowser
62
+ klient={klient}
63
+ workspaces={workspaces.data}
64
+ onSelect={(workspace) => setWorkspaceId(workspace.id)}
65
+ />
66
+ </aside>
67
+ <div className="min-h-0 min-w-0 flex-1 overflow-y-auto p-4">
68
+ {workspaceId === null ? (
69
+ <div className="flex h-full items-center justify-center text-[12px] text-neutral-600 italic">
70
+ select a workspace to inspect
71
+ </div>
72
+ ) : snapshot.isError ? (
73
+ <ErrorLine error={snapshot.error} />
74
+ ) : data === undefined ? (
75
+ <div className="text-[12px] text-neutral-600">loading workspace snapshot…</div>
76
+ ) : (
77
+ <div className="space-y-4">
78
+ <SnapshotPanel title="Workspace">
79
+ <SnapshotRow label="id" value={data.metadata.id} />
80
+ <SnapshotRow label="name" value={data.metadata.name} />
81
+ <SnapshotRow label="root" value={data.metadata.root} />
82
+ <SnapshotRow label="lifecycle" value={<Badge tone="sky">{data.lifecycle}</Badge>} />
83
+ </SnapshotPanel>
84
+ <SnapshotPanel title="Program">
85
+ <SnapshotRow label="binding" value={`${data.program.binding.workspaceId} / ${data.program.binding.runtimeId}`} />
86
+ <SnapshotRow label="status" value={<Badge tone={data.program.status === 'ready' ? 'green' : 'neutral'}>{data.program.status}</Badge>} />
87
+ <SnapshotRow label="ready" value={String(data.program.ready)} />
88
+ <SnapshotRow label="generation" value={data.program.generation ?? 'unavailable'} />
89
+ <SnapshotRow label="trusted" value={data.program.trusted === undefined ? 'unknown' : String(data.program.trusted)} />
90
+ <SnapshotRow label="skills" value={`${data.program.catalog.skills.total} total / ${data.program.catalog.skills.invocable} invocable / ${data.program.catalog.skills.skipped} skipped`} />
91
+ <SnapshotRow label="agent profiles" value={String(data.program.catalog.agentProfiles)} />
92
+ <SnapshotRow label="MCP servers" value={String(data.program.catalog.mcpServers)} />
93
+ </SnapshotPanel>
94
+ <SnapshotPanel title="Runtimes">
95
+ {data.runtimes.runtimes.length === 0 ? (
96
+ <div className="text-[11px] text-neutral-600">no current generations</div>
97
+ ) : data.runtimes.runtimes.map((runtime) => (
98
+ <div key={`${runtime.runtimeId}:${runtime.generation}`} className="rounded border border-neutral-800 bg-neutral-950/40 p-2">
99
+ <div className="mb-1 flex items-center gap-2">
100
+ <span className="font-mono text-[12px] text-neutral-200">{runtime.runtimeId}</span>
101
+ <Badge tone={runtime.status === 'ready' ? 'green' : 'neutral'}>{runtime.status}</Badge>
102
+ </div>
103
+ <SnapshotRow label="generation" value={runtime.generation} />
104
+ <SnapshotRow label="capabilities" value={runtime.capabilities.join(', ') || 'none'} />
105
+ </div>
106
+ ))}
107
+ </SnapshotPanel>
108
+ <SnapshotPanel title="Source provenance">
109
+ <SnapshotRow label="skills" value={data.program.sources.skills.map((source) => `${source.source}:${source.count}`).join(', ') || 'none'} />
110
+ <SnapshotRow label="skill roots" value={data.program.sources.skillRoots.join(', ') || 'none'} />
111
+ <SnapshotRow label="agent profiles" value={data.program.sources.agentProfiles.map((source) => `${source.sourceId}:${source.profiles.join('|')}`).join(', ') || 'none'} />
112
+ <SnapshotRow label="instructions" value={data.program.sources.instructionPaths.join(', ') || 'none'} />
113
+ <SnapshotRow label="MCP" value={data.program.sources.mcpServers.join(', ') || 'none'} />
114
+ </SnapshotPanel>
115
+ </div>
116
+ )}
117
+ </div>
118
+ </div>
119
+ );
120
+ }
121
+
122
+ function SnapshotPanel({ title, children }: { readonly title: string; readonly children: ReactNode }) {
123
+ return (
124
+ <section className="rounded border border-neutral-800 bg-neutral-900/30 p-3">
125
+ <h2 className="mb-2 text-[11px] font-semibold uppercase tracking-wider text-neutral-400">{title}</h2>
126
+ <div className="space-y-1">{children}</div>
127
+ </section>
128
+ );
129
+ }
130
+
131
+ function SnapshotRow({ label, value }: { readonly label: string; readonly value: ReactNode }) {
132
+ return (
133
+ <div className="grid grid-cols-[120px_minmax(0,1fr)] gap-2 text-[11px]">
134
+ <span className="text-neutral-600">{label}</span>
135
+ <span className="min-w-0 break-all font-mono text-neutral-300">{value}</span>
136
+ </div>
137
+ );
138
+ }
apps/kimi-inspect/src/components/audit/AuditPanel.tsx ADDED
@@ -0,0 +1,188 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Audit panel — the `Audit` tab of the chat view's right dock
3
+ * (`RightPanel`): replays how the visible `ChatStore` was built, entry by
4
+ * entry. It used to be a standalone column docked inside the chat view.
5
+ *
6
+ * - Timeline (draggable slider + entry list): every REST history page,
7
+ * every WS message (entity / delta / state), and every channel event
8
+ * (subscribe ack, reconnect, catch-up fallback, prompt/cancel), with its
9
+ * timestamp.
10
+ * - Detail tabs for the selected entry: `Diff` (structural diff vs the
11
+ * previous entry — added/modified/removed colored), `State` (the full
12
+ * store state at that point: entity timeline plus the interaction /
13
+ * task / todo / session.state entities), `Event` (the raw REST
14
+ * request/response or WS payload).
15
+ */
16
+
17
+ import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
18
+
19
+ import { diffValue, type DiffNode } from '../../audit/diff';
20
+ import { serializeState } from '../../audit/serialize';
21
+ import type { AuditEntry, AuditTrail } from '../../audit/trail';
22
+ import { tailTrunc } from '../../audit/truncate';
23
+ import { EMPTY_CHAT_STATE } from '../../transcript/store';
24
+ import { Badge } from '../../ui';
25
+ import { plainNode, StateTree } from './StateTree';
26
+
27
+ const KIND_TONE: Record<AuditEntry['kind'], 'sky' | 'green' | 'neutral'> = {
28
+ rest: 'sky',
29
+ ws: 'green',
30
+ event: 'neutral',
31
+ };
32
+
33
+ function fmtTime(iso: string): string {
34
+ const d = new Date(iso);
35
+ const base = d.toLocaleTimeString('en-GB', { hour12: false });
36
+ return `${base}.${String(d.getMilliseconds()).padStart(3, '0')}`;
37
+ }
38
+
39
+ /** Raw payload view: full JSON, with long strings tail-truncated. */
40
+ function EventJson({ entry }: { entry: AuditEntry }) {
41
+ const payload = useMemo(() => {
42
+ switch (entry.kind) {
43
+ case 'rest':
44
+ return {
45
+ request: entry.request,
46
+ mode: entry.mode,
47
+ messageCount: entry.messageCount,
48
+ inFlight: entry.inFlight,
49
+ };
50
+ case 'ws':
51
+ return entry.message;
52
+ case 'event':
53
+ return { event: entry.event, detail: entry.detail };
54
+ }
55
+ }, [entry]);
56
+ const text = JSON.stringify(
57
+ payload,
58
+ (_key, value: unknown) => (typeof value === 'string' ? tailTrunc(value) : value),
59
+ 2,
60
+ );
61
+ return (
62
+ <pre className="p-2 font-mono text-[11px] leading-relaxed whitespace-pre-wrap break-all text-neutral-300">
63
+ {text}
64
+ </pre>
65
+ );
66
+ }
67
+
68
+ export function AuditPanel({ trail }: { trail: AuditTrail }) {
69
+ const entries = useSyncExternalStore(trail.subscribe, () => trail.getEntries());
70
+ const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
71
+ const [follow, setFollow] = useState(true);
72
+ const [tab, setTab] = useState<'diff' | 'state' | 'event'>('diff');
73
+ const listRef = useRef<HTMLDivElement>(null);
74
+
75
+ const current: AuditEntry | undefined =
76
+ entries.find((entry) => entry.index === selectedIndex) ?? entries.at(-1);
77
+ const currentPos = current !== undefined ? entries.indexOf(current) : -1;
78
+
79
+ // Follow-latest: jump to (and scroll to) the newest entry as it lands.
80
+ useEffect(() => {
81
+ if (!follow || entries.length === 0) return;
82
+ setSelectedIndex(entries.at(-1)!.index);
83
+ const list = listRef.current;
84
+ if (list !== null) list.scrollTop = list.scrollHeight;
85
+ }, [entries, follow]);
86
+
87
+ const root: DiffNode | null = useMemo(() => {
88
+ if (current === undefined || tab === 'event') return null;
89
+ if (tab === 'state') return plainNode(serializeState(current.state));
90
+ const prevState =
91
+ currentPos > 0 ? (entries[currentPos - 1]?.state ?? EMPTY_CHAT_STATE) : EMPTY_CHAT_STATE;
92
+ return diffValue(serializeState(prevState), serializeState(current.state));
93
+ }, [current, currentPos, entries, tab]);
94
+
95
+ return (
96
+ <div className="flex h-full flex-col">
97
+ <div className="flex items-center gap-2 border-b border-neutral-800 px-3 py-2">
98
+ <span className="text-[12px] font-medium text-neutral-200">Transcript audit</span>
99
+ <Badge tone="neutral">{entries.length} entries</Badge>
100
+ </div>
101
+
102
+ <div className="flex items-center gap-2 border-b border-neutral-800 px-3 py-1.5">
103
+ <input
104
+ type="range"
105
+ className="min-w-0 flex-1 accent-sky-500"
106
+ min={0}
107
+ max={Math.max(0, entries.length - 1)}
108
+ value={Math.max(0, currentPos)}
109
+ disabled={entries.length === 0}
110
+ onChange={(e) => {
111
+ const pos = Number(e.target.value);
112
+ const entry = entries[pos];
113
+ if (entry === undefined) return;
114
+ setFollow(pos === entries.length - 1);
115
+ setSelectedIndex(entry.index);
116
+ }}
117
+ />
118
+ <label className="flex items-center gap-1 text-[10px] text-neutral-500 select-none">
119
+ <input
120
+ type="checkbox"
121
+ checked={follow}
122
+ onChange={(e) => setFollow(e.target.checked)}
123
+ className="accent-sky-500"
124
+ />
125
+ follow
126
+ </label>
127
+ </div>
128
+
129
+ <div ref={listRef} className="h-56 shrink-0 overflow-y-auto border-b border-neutral-800">
130
+ {entries.length === 0 ? (
131
+ <div className="px-3 py-2 text-[11px] text-neutral-600 italic">
132
+ Nothing recorded yet — the initial history load is still running.
133
+ </div>
134
+ ) : null}
135
+ {entries.map((entry, pos) => (
136
+ <div
137
+ key={entry.index}
138
+ className={`flex cursor-pointer items-center gap-2 px-3 py-1 text-[11px] hover:bg-neutral-800/70 ${
139
+ entry.index === current?.index ? 'bg-sky-950/50' : ''
140
+ }`}
141
+ onClick={() => {
142
+ setFollow(pos === entries.length - 1);
143
+ setSelectedIndex(entry.index);
144
+ }}
145
+ >
146
+ <span className="w-8 shrink-0 font-mono text-neutral-600">#{entry.index}</span>
147
+ <span className="shrink-0 font-mono text-neutral-500">{fmtTime(entry.at)}</span>
148
+ <Badge tone={KIND_TONE[entry.kind]}>{entry.kind}</Badge>
149
+ <span className="min-w-0 truncate text-neutral-400" title={entry.summary}>
150
+ {entry.summary}
151
+ </span>
152
+ </div>
153
+ ))}
154
+ </div>
155
+
156
+ <div className="flex items-center gap-1 border-b border-neutral-800 px-3 py-1.5">
157
+ {(['diff', 'state', 'event'] as const).map((name) => (
158
+ <button
159
+ key={name}
160
+ className={`rounded px-2 py-0.5 text-[11px] ${
161
+ tab === name
162
+ ? 'bg-neutral-800 text-neutral-100'
163
+ : 'text-neutral-500 hover:text-neutral-300'
164
+ }`}
165
+ onClick={() => setTab(name)}
166
+ >
167
+ {name === 'diff' ? 'Diff vs prev' : name === 'state' ? 'State' : 'Event'}
168
+ </button>
169
+ ))}
170
+ {current !== undefined ? (
171
+ <span className="ml-auto font-mono text-[10px] text-neutral-600">
172
+ entry #{current.index}
173
+ </span>
174
+ ) : null}
175
+ </div>
176
+
177
+ <div className="min-h-0 flex-1 overflow-y-auto px-2">
178
+ {current === undefined ? (
179
+ <div className="px-1 py-2 text-[11px] text-neutral-600 italic">No entry selected.</div>
180
+ ) : tab === 'event' ? (
181
+ <EventJson entry={current} />
182
+ ) : root !== null ? (
183
+ <StateTree root={root} defaultDepth={tab === 'state' ? 2 : 0} />
184
+ ) : null}
185
+ </div>
186
+ </div>
187
+ );
188
+ }
apps/kimi-inspect/src/components/audit/StateTree.test.tsx ADDED
@@ -0,0 +1,161 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * StateTree rendering tests (SSR via renderToStaticMarkup — no DOM needed).
3
+ * Locks in two audit-panel readability rules:
4
+ * 1. Unchanged subtrees collapse into `{ …N }` rows — never a one-line
5
+ * compact-JSON dump (the copy-on-write fast path gives them no diff
6
+ * children, and rendering the raw value destroyed the layout).
7
+ * 2. Whole-subtree adds expand into fully fielded, indented tree rows.
8
+ */
9
+
10
+ import type { AssistantMessage, StepMessage, TurnMessage } from '@moonshot-ai/kap-server/protocol';
11
+ import { renderToStaticMarkup } from 'react-dom/server';
12
+ import { describe, expect, it } from 'vitest';
13
+
14
+ import { diffValue } from '../../audit/diff';
15
+ import { serializeState } from '../../audit/serialize';
16
+ import { EMPTY_CHAT_STATE, type ChatState } from '../../transcript/store';
17
+ import { plainNode, StateTree } from './StateTree';
18
+
19
+ const T0 = Date.parse('2026-01-01T00:00:00.000Z');
20
+ let tick = 0;
21
+
22
+ function ts(): number {
23
+ tick += 1;
24
+ return T0 + tick * 1000;
25
+ }
26
+
27
+ function turnMsg(n: number, text?: string): TurnMessage {
28
+ return {
29
+ type: 'turn',
30
+ session_id: 's1',
31
+ agent_id: 'main',
32
+ timestamp: ts(),
33
+ turn_id: `t${n}`,
34
+ ordinal: n,
35
+ status: 'completed',
36
+ origin: { kind: 'user' },
37
+ user_message_id: text,
38
+ };
39
+ }
40
+
41
+ function stepMsg(stepId: string): StepMessage {
42
+ return {
43
+ type: 'step',
44
+ session_id: 's1',
45
+ agent_id: 'main',
46
+ timestamp: ts(),
47
+ step_id: stepId,
48
+ turn_id: stepId.split('.')[0] ?? 't0',
49
+ ordinal: Number(stepId.split('.')[1] ?? '1'),
50
+ status: 'running',
51
+ };
52
+ }
53
+
54
+ function assistantMsg(stepId: string, text: string): AssistantMessage {
55
+ return {
56
+ type: 'assistant',
57
+ session_id: 's1',
58
+ agent_id: 'main',
59
+ timestamp: ts(),
60
+ message_id: `${stepId}.a0`,
61
+ turn_id: stepId.split('.')[0] ?? 't0',
62
+ step_id: stepId,
63
+ status: 'streaming',
64
+ text,
65
+ };
66
+ }
67
+
68
+ type FlatMessage = TurnMessage | StepMessage | AssistantMessage;
69
+
70
+ function stateWithTimeline(items: readonly FlatMessage[]): ChatState {
71
+ return {
72
+ ...EMPTY_CHAT_STATE,
73
+ entries: items.map((message) => ({
74
+ key:
75
+ message.type === 'turn'
76
+ ? `turn:${message.turn_id}`
77
+ : message.type === 'step'
78
+ ? `step:${message.step_id}`
79
+ : `assistant:${message.message_id}`,
80
+ message,
81
+ })),
82
+ };
83
+ }
84
+
85
+ describe('StateTree', () => {
86
+ it('collapses unchanged subtrees instead of dumping compact JSON', () => {
87
+ const t0 = turnMsg(0, 'PROMPT_ZERO');
88
+ const prev = stateWithTimeline([t0, turnMsg(1, 'PROMPT_ONE')]);
89
+ const next: ChatState = stateWithTimeline([t0, turnMsg(1, 'PROMPT_ONE_V2')]);
90
+ const html = renderToStaticMarkup(
91
+ <StateTree root={diffValue(serializeState(prev), serializeState(next))} />,
92
+ );
93
+ // No one-line JSON blob anywhere.
94
+ expect(html).not.toContain('{"type"');
95
+ // The unchanged turn t0 stays folded: its marker is not rendered…
96
+ expect(html).not.toContain('PROMPT_ZERO');
97
+ // …while the modified turn opens and shows old → new.
98
+ expect(html).toContain('PROMPT_ONE_V2');
99
+ expect(html).toContain('PROMPT_ONE');
100
+ expect(html).toContain('→');
101
+ });
102
+
103
+ it('expands whole-subtree adds into full field rows (all keys, no JSON dump)', () => {
104
+ const root = diffValue(
105
+ serializeState(EMPTY_CHAT_STATE),
106
+ serializeState(stateWithTimeline([turnMsg(0, 'HELLO')])),
107
+ );
108
+ const html = renderToStaticMarkup(<StateTree root={root} />);
109
+ expect(html).not.toContain('{"type"');
110
+ for (const field of ['turn_id', 'ordinal', 'status', 'origin', 'timestamp', 'agent_id']) {
111
+ expect(html).toContain(field);
112
+ }
113
+ expect(html).toContain('HELLO');
114
+ });
115
+
116
+ it('expands added subtrees with id-based keys and renders closing braces', () => {
117
+ const html = renderToStaticMarkup(
118
+ <StateTree
119
+ root={diffValue(
120
+ serializeState(EMPTY_CHAT_STATE),
121
+ serializeState(
122
+ stateWithTimeline([turnMsg(0), stepMsg('t0.1'), assistantMsg('t0.1', 'hmm')]),
123
+ ),
124
+ )}
125
+ />,
126
+ );
127
+ // Array children are keyed by their ids, not #indices.
128
+ expect(html).toContain('t0.1');
129
+ expect(html).toContain('t0.1.a0');
130
+ expect(html).not.toContain('#0');
131
+ // Open containers end with an explicit closing brace row.
132
+ expect(html).toContain(']');
133
+ expect(html).toContain('}');
134
+ });
135
+
136
+ it('plain state mode opens to defaultDepth and shows all top-level fields', () => {
137
+ const html = renderToStaticMarkup(
138
+ <StateTree
139
+ root={plainNode(serializeState(stateWithTimeline([turnMsg(0)])))}
140
+ defaultDepth={2}
141
+ />,
142
+ );
143
+ for (const field of ['timeline', 'interactions', 'tasks', 'todos', 'hasMoreOlder']) {
144
+ expect(html).toContain(field);
145
+ }
146
+ expect(html).not.toContain('{"type"');
147
+ });
148
+
149
+ it('collapses multiline strings into a hover-preview button', () => {
150
+ const root = plainNode({ note: 'line one\nline two\nline three', single: 'one-liner' });
151
+ const html = renderToStaticMarkup(<StateTree root={root} defaultDepth={1} />);
152
+ // The multiline value renders as a compact button: first-line preview +
153
+ // line count, with the remaining lines NOT inlined into the tree (they
154
+ // only appear in the hover panel, which needs a real mouse to open).
155
+ expect(html).toContain('&quot;line one&quot; ⏎ 3');
156
+ expect(html).not.toContain('line two');
157
+ expect(html).not.toContain('line three');
158
+ // Single-line strings keep the inline rendering.
159
+ expect(html).toContain('&quot;one-liner&quot;');
160
+ });
161
+ });
apps/kimi-inspect/src/components/audit/StateTree.tsx ADDED
@@ -0,0 +1,259 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Diff-aware state tree for the audit panel.
3
+ *
4
+ * Renders a serialized `ChatState` (see `audit/serialize.ts`) as a
5
+ * collapsible tree, colored by the structural diff against the previous
6
+ * trail entry: added = green, removed = red + strikethrough, modified =
7
+ * amber (`old → new` on leaves). Every field is rendered — long strings
8
+ * are tail-truncated (`tailTrunc`) but no key is ever dropped, and a string
9
+ * containing newlines collapses into one compact button (first-line preview
10
+ * + line count) whose hover pops a `fixed` full-text panel at the button's
11
+ * right edge.
12
+ *
13
+ * The tree is driven entirely by a `DiffNode` root: diff mode passes
14
+ * `diffValue(prev, next)`, plain state mode passes `plainNode(value)`.
15
+ * Diff nodes that carry an object/array but no children (whole-subtree
16
+ * added/removed, or reference-equal unchanged subtrees collapsed by the
17
+ * diff fast path) are expanded into same-status blocks at render time
18
+ * (`blockNode`) — they show as collapsible `{ …N }` rows instead of an
19
+ * unreadable one-line JSON dump.
20
+ */
21
+
22
+ import { useEffect, useRef, useState } from 'react';
23
+
24
+ import { elementId, type DiffNode, type DiffStatus } from '../../audit/diff';
25
+ import { tailTrunc } from '../../audit/truncate';
26
+
27
+ /** Build an all-`unchanged` tree over a plain value (plain state mode). */
28
+ export function plainNode(value: unknown): DiffNode {
29
+ return blockNode(value, 'unchanged');
30
+ }
31
+
32
+ function blockNode(value: unknown, status: DiffStatus): DiffNode {
33
+ const current = status === 'removed' ? undefined : value;
34
+ const prev = status === 'removed' ? value : undefined;
35
+ if (typeof value === 'object' && value !== null) {
36
+ const children = new Map<string, DiffNode>();
37
+ const entries: Array<readonly [string, unknown]> = Array.isArray(value)
38
+ ? value.map((item, index) => [elementId(item) ?? `#${index}`, item] as const)
39
+ : Object.entries(value as Record<string, unknown>).filter(([, item]) => item !== undefined);
40
+ for (const [key, item] of entries) children.set(key, blockNode(item, status));
41
+ return { status, value: current, prev, children };
42
+ }
43
+ return { status, value: current, prev };
44
+ }
45
+
46
+ function subtreeHasChanges(node: DiffNode): boolean {
47
+ if (node.status !== 'unchanged') return true;
48
+ if (node.children === undefined) return false;
49
+ for (const child of node.children.values()) {
50
+ if (subtreeHasChanges(child)) return true;
51
+ }
52
+ return false;
53
+ }
54
+
55
+ const ROW_TONE: Record<DiffStatus, string> = {
56
+ unchanged: 'border-transparent',
57
+ added: 'border-green-800 bg-green-950/40',
58
+ removed: 'border-red-800 bg-red-950/40',
59
+ modified: 'border-amber-700 bg-amber-950/30',
60
+ };
61
+
62
+ const TEXT_TONE: Record<DiffStatus, string> = {
63
+ unchanged: 'text-neutral-400',
64
+ added: 'text-green-400',
65
+ removed: 'text-red-400 line-through',
66
+ modified: 'text-amber-300',
67
+ };
68
+
69
+ function Leaf({ value, tone }: { value: unknown; tone: DiffStatus }) {
70
+ const className = TEXT_TONE[tone];
71
+ if (value === null) return <span className={className}>null</span>;
72
+ if (value === undefined) return <span className={className}>undefined</span>;
73
+ if (typeof value === 'string') {
74
+ if (value.includes('\n')) return <MultilineString value={value} tone={tone} />;
75
+ return (
76
+ <span className={`${className} whitespace-pre-wrap break-all`}>"{tailTrunc(value)}"</span>
77
+ );
78
+ }
79
+ if (typeof value === 'number' || typeof value === 'boolean') {
80
+ return <span className={className}>{String(value)}</span>;
81
+ }
82
+ return <span className={className}>{JSON.stringify(value) ?? 'unknown'}</span>;
83
+ }
84
+
85
+ /**
86
+ * Multiline string leaf: rendered as one compact button (first-line preview
87
+ * + line count) so a prompt or tool output never stretches the tree. Hovering
88
+ * the button pops a `fixed` panel at its right edge with the full text —
89
+ * `fixed` because the tree sits inside an `overflow-y-auto` column that
90
+ * would clip an absolutely-positioned popup. A 150 ms close delay lets the
91
+ * pointer travel from the button into the panel (to scroll or select).
92
+ * `popupStyle` keeps the whole panel inside the viewport: the total height
93
+ * is capped by the space left below the clamped top edge, and the panel
94
+ * flips to the button's left side when the remaining right space is cramped.
95
+ */
96
+ function popupStyle(rect: DOMRect): React.CSSProperties {
97
+ const margin = 8;
98
+ const vh = window.innerHeight;
99
+ const vw = window.innerWidth;
100
+ const maxHeight = Math.max(160, Math.floor(vh * 0.7));
101
+ const top = Math.max(margin, Math.min(rect.top, vh - maxHeight - margin));
102
+ const rightSpace = vw - rect.right - margin * 2;
103
+ if (rightSpace >= 320) {
104
+ return { left: rect.right + margin, top, maxHeight, width: Math.min(560, rightSpace) };
105
+ }
106
+ const leftSpace = rect.left - margin * 2;
107
+ return {
108
+ right: vw - rect.left + margin,
109
+ top,
110
+ maxHeight,
111
+ width: Math.min(560, Math.max(280, leftSpace)),
112
+ };
113
+ }
114
+
115
+ function MultilineString({ value, tone }: { value: string; tone: DiffStatus }) {
116
+ const [rect, setRect] = useState<DOMRect | null>(null);
117
+ const closeTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
118
+ useEffect(() => () => clearTimeout(closeTimer.current), []);
119
+
120
+ const lines = value.split('\n');
121
+ const firstLine = lines[0] ?? '';
122
+ const preview = firstLine.length > 40 ? `${firstLine.slice(0, 40)}…` : firstLine;
123
+
124
+ const open = (e: React.MouseEvent<HTMLButtonElement>) => {
125
+ clearTimeout(closeTimer.current);
126
+ setRect(e.currentTarget.getBoundingClientRect());
127
+ };
128
+ const scheduleClose = () => {
129
+ clearTimeout(closeTimer.current);
130
+ closeTimer.current = setTimeout(() => setRect(null), 150);
131
+ };
132
+
133
+ return (
134
+ <>
135
+ <button
136
+ type="button"
137
+ className={`${TEXT_TONE[tone]} cursor-pointer rounded border border-neutral-700 bg-neutral-900 px-1 hover:border-sky-600`}
138
+ onMouseEnter={open}
139
+ onMouseLeave={scheduleClose}
140
+ >
141
+ {`"${preview}" ⏎ ${lines.length}`}
142
+ </button>
143
+ {rect !== null ? (
144
+ <div
145
+ className="fixed z-50 flex flex-col rounded border border-neutral-700 bg-neutral-950 shadow-xl"
146
+ style={popupStyle(rect)}
147
+ onMouseEnter={() => clearTimeout(closeTimer.current)}
148
+ onMouseLeave={scheduleClose}
149
+ >
150
+ <div className="shrink-0 border-b border-neutral-800 px-3 py-1 text-[10px] text-neutral-500">
151
+ {lines.length} lines · {value.length} chars
152
+ </div>
153
+ <pre className="min-h-0 flex-1 overflow-auto px-3 py-2 font-mono text-[11px] whitespace-pre-wrap break-all text-neutral-200">
154
+ {tailTrunc(value, 5000)}
155
+ </pre>
156
+ </div>
157
+ ) : null}
158
+ </>
159
+ );
160
+ }
161
+
162
+ function Node({
163
+ name,
164
+ node,
165
+ depth,
166
+ defaultDepth,
167
+ }: {
168
+ name: string;
169
+ node: DiffNode;
170
+ depth: number;
171
+ defaultDepth: number;
172
+ }) {
173
+ // Childless object/array nodes (whole-subtree added/removed, or
174
+ // reference-equal unchanged subtrees) are expanded into same-status
175
+ // blocks so every field stays visible — never a one-line JSON dump.
176
+ const raw = node.status === 'removed' ? node.prev : node.value;
177
+ const effective =
178
+ node.children === undefined && typeof raw === 'object' && raw !== null
179
+ ? blockNode(raw, node.status)
180
+ : node;
181
+
182
+ const [open, setOpen] = useState(() => subtreeHasChanges(effective) || depth < defaultDepth);
183
+ const label = <span className={TEXT_TONE[effective.status]}>{name}: </span>;
184
+
185
+ if (effective.children === undefined) {
186
+ return (
187
+ <div
188
+ className={`border-l-2 px-1 font-mono text-[11px] leading-[1.7] ${ROW_TONE[effective.status]}`}
189
+ style={{ paddingLeft: `${depth * 14 + 4}px` }}
190
+ >
191
+ {label}
192
+ {effective.status === 'modified' ? (
193
+ <>
194
+ <Leaf value={effective.prev} tone="removed" />
195
+ <span className="text-neutral-600"> → </span>
196
+ <Leaf value={effective.value} tone="added" />
197
+ </>
198
+ ) : (
199
+ <Leaf value={raw} tone={effective.status} />
200
+ )}
201
+ </div>
202
+ );
203
+ }
204
+
205
+ const isArray = Array.isArray(raw);
206
+ const [openBrace, closeBrace] = isArray ? ['[', ']'] : ['{', '}'];
207
+ return (
208
+ <div>
209
+ <div
210
+ className={`cursor-pointer select-none border-l-2 px-1 font-mono text-[11px] leading-[1.7] hover:bg-neutral-800/70 ${ROW_TONE[effective.status]}`}
211
+ style={{ paddingLeft: `${depth * 14 + 4}px` }}
212
+ onClick={() => setOpen((v) => !v)}
213
+ >
214
+ <span className="text-neutral-600">{open ? '▾ ' : '▸ '}</span>
215
+ {label}
216
+ <span className="text-neutral-600">
217
+ {open ? openBrace : `${openBrace} …${effective.children.size} ${closeBrace}`}
218
+ </span>
219
+ </div>
220
+ {open ? (
221
+ <>
222
+ {[...effective.children.entries()].map(([key, child]) => (
223
+ <Node key={key} name={key} node={child} depth={depth + 1} defaultDepth={defaultDepth} />
224
+ ))}
225
+ <div
226
+ className={`border-l-2 px-1 font-mono text-[11px] leading-[1.7] ${ROW_TONE[effective.status]}`}
227
+ style={{ paddingLeft: `${depth * 14 + 4}px` }}
228
+ >
229
+ <span className="text-neutral-600">{closeBrace}</span>
230
+ </div>
231
+ </>
232
+ ) : null}
233
+ </div>
234
+ );
235
+ }
236
+
237
+ export function StateTree({
238
+ root,
239
+ defaultDepth = 0,
240
+ }: {
241
+ root: DiffNode;
242
+ /** Unchanged levels opened by default (diff mode 0 = only changes auto-open). */
243
+ defaultDepth?: number;
244
+ }) {
245
+ if (root.children === undefined) {
246
+ return (
247
+ <div className="py-1">
248
+ <Node name="state" node={root} depth={0} defaultDepth={defaultDepth} />
249
+ </div>
250
+ );
251
+ }
252
+ return (
253
+ <div className="py-1">
254
+ {[...root.children.entries()].map(([key, child]) => (
255
+ <Node key={key} name={key} node={child} depth={0} defaultDepth={defaultDepth} />
256
+ ))}
257
+ </div>
258
+ );
259
+ }
apps/kimi-inspect/src/components/di/DiEventsPanel.tsx ADDED
@@ -0,0 +1,157 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * DI Events panel — event-subscription introspection
3
+ * (`IDebugEventsService.subscriptions`), two merged sides:
4
+ *
5
+ * - Subscriptions: the unit-book side — every materialized unit's ledger
6
+ * entries labeled as an event subscription (`on:<name>` from a named
7
+ * Emitter or the fiber `on` capability, `disposable:EventSubscription`
8
+ * from an unnamed one), grouped by scope path;
9
+ * - Bus listeners: the emitter-side fallback — per-`IEventBus` listener
10
+ * counts (`*` = the full stream) plus the global `IEventService` count,
11
+ * which also cover subscriptions never registered on a unit book.
12
+ *
13
+ * Pure React + Tailwind.
14
+ */
15
+ import type {
16
+ DebugEventBusSnapshot,
17
+ DebugEventSubscription,
18
+ DebugEventSubscriptions,
19
+ } from '@moonshot-ai/agent-core-v2/features/debugEvents/debugEvents';
20
+
21
+ import { Badge } from '../../ui';
22
+
23
+ const KIND_TONES: Record<DebugEventSubscription['kind'], 'neutral' | 'sky' | 'violet'> = {
24
+ disposer: 'neutral',
25
+ effect: 'sky',
26
+ ledger: 'violet',
27
+ };
28
+
29
+ export function DiEventsPanel({ data }: { data: DebugEventSubscriptions }) {
30
+ const groups = groupByScope(data.subscriptions);
31
+ return (
32
+ <div>
33
+ <div className="mb-1 text-[10px] font-semibold tracking-wider text-neutral-600 uppercase">
34
+ subscriptions ({data.subscriptions.length})
35
+ </div>
36
+ {groups.length === 0 ? (
37
+ <div className="mb-4 text-[11px] text-neutral-600 italic">
38
+ no event subscriptions on any unit book
39
+ </div>
40
+ ) : (
41
+ groups.map(([scopePath, subs]) => (
42
+ <div
43
+ key={scopePath}
44
+ className="mb-2 rounded-lg border border-neutral-800 bg-neutral-900/60"
45
+ >
46
+ <div className="border-b border-neutral-800/60 px-3 py-2">
47
+ <span className="font-mono text-[11px] text-neutral-200">{scopePath}</span>
48
+ <span className="ml-2 text-[10px] text-neutral-600">{subs.length}</span>
49
+ </div>
50
+ <div className="px-3 py-2">
51
+ {subs.map((sub, i) => (
52
+ <div
53
+ key={`${sub.unit}:${sub.label}:${i}`}
54
+ className="mb-1 flex items-center gap-2 rounded border border-neutral-800/70 bg-neutral-950/40 px-2 py-1.5"
55
+ >
56
+ <span
57
+ className="min-w-0 truncate font-mono text-[11px] text-neutral-200"
58
+ title={sub.unit}
59
+ >
60
+ {sub.unit}
61
+ </span>
62
+ {sub.uid !== undefined ? (
63
+ <span className="shrink-0 text-[10px] text-neutral-600">#{sub.uid}</span>
64
+ ) : null}
65
+ <span
66
+ className="shrink-0 font-mono text-[10px] text-sky-400"
67
+ title={sub.label}
68
+ >
69
+ {sub.label}
70
+ </span>
71
+ <span className="ml-auto shrink-0">
72
+ <Badge tone={KIND_TONES[sub.kind]}>{sub.kind}</Badge>
73
+ </span>
74
+ </div>
75
+ ))}
76
+ </div>
77
+ </div>
78
+ ))
79
+ )}
80
+ <div className="mt-4 mb-1 text-[10px] font-semibold tracking-wider text-neutral-600 uppercase">
81
+ bus listeners
82
+ </div>
83
+ {data.buses.length === 0 && data.globalListeners === undefined ? (
84
+ <div className="text-[11px] text-neutral-600 italic">no materialized event buses</div>
85
+ ) : (
86
+ <div className="rounded-lg border border-neutral-800 bg-neutral-900/60 px-3 py-2">
87
+ {data.globalListeners !== undefined ? (
88
+ <BusRow scopePath="app" type="eventService (global)" count={data.globalListeners} />
89
+ ) : null}
90
+ {data.buses.flatMap((bus) => busRows(bus))}
91
+ </div>
92
+ )}
93
+ </div>
94
+ );
95
+ }
96
+
97
+ function groupByScope(
98
+ subs: readonly DebugEventSubscription[],
99
+ ): [string, DebugEventSubscription[]][] {
100
+ const map = new Map<string, DebugEventSubscription[]>();
101
+ for (const sub of subs) {
102
+ const group = map.get(sub.scopePath) ?? [];
103
+ group.push(sub);
104
+ map.set(sub.scopePath, group);
105
+ }
106
+ return [...map.entries()];
107
+ }
108
+
109
+ function busRows(bus: DebugEventBusSnapshot) {
110
+ const rows = [
111
+ <BusRow key={`${bus.scopePath}:*`} scopePath={bus.scopePath} type="*" count={bus.all} />,
112
+ ];
113
+ for (const type of Object.keys(bus.perType).toSorted()) {
114
+ rows.push(
115
+ <BusRow
116
+ key={`${bus.scopePath}:${type}`}
117
+ scopePath={bus.scopePath}
118
+ type={type}
119
+ count={bus.perType[type] ?? 0}
120
+ />,
121
+ );
122
+ }
123
+ for (const agentId of Object.keys(bus.perAgent).toSorted()) {
124
+ const count = bus.perAgent[agentId] ?? 0;
125
+ rows.push(
126
+ <BusRow
127
+ key={`${bus.scopePath}:agent:${agentId}`}
128
+ scopePath={bus.scopePath}
129
+ type={`agent:${agentId}`}
130
+ count={count}
131
+ />,
132
+ );
133
+ }
134
+ return rows;
135
+ }
136
+
137
+ function BusRow({
138
+ scopePath,
139
+ type,
140
+ count,
141
+ }: {
142
+ scopePath: string;
143
+ type: string;
144
+ count: number;
145
+ }) {
146
+ return (
147
+ <div className="flex items-center gap-2 py-0.5">
148
+ <span className="min-w-0 truncate font-mono text-[10px] text-neutral-500" title={scopePath}>
149
+ {scopePath}
150
+ </span>
151
+ <span className="shrink-0 font-mono text-[11px] text-neutral-200" title={type}>
152
+ {type}
153
+ </span>
154
+ <span className="ml-auto shrink-0 font-mono text-[11px] text-neutral-400">{count}</span>
155
+ </div>
156
+ );
157
+ }
apps/kimi-inspect/src/components/di/DiGraphPanel.tsx ADDED
@@ -0,0 +1,374 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * DI Deps panel — the persistent dependency DAG (`IDebugGraphService.graph`)
3
+ * as Finder-style Miller columns with path-scoped relation markers. Edges
4
+ * point consumer → dependency, so "X depends on Y" ⟺ an edge
5
+ * `from: X → to: Y`:
6
+ *
7
+ * - column 0 lists the roots: services that never appear as an edge's `to`
8
+ * (nothing consumes them, isolated nodes included), token-sorted,
9
+ * constrained by the scope filter; each root row carries the level-0
10
+ * color bar;
11
+ * - clicking a row in column N truncates the expansion path there and
12
+ * opens column N+1 with that service's direct dependencies (along `to`,
13
+ * unfiltered — cross-scope rows get a scopePath subtitle);
14
+ * - relation bars are path-scoped: a row's left edge carries one 3px bar
15
+ * per path ancestor that depends on it DIRECTLY (edge ancestor → node),
16
+ * ordered by ancestor depth — the direct parent always contributes one,
17
+ * and a grandparent with a skip-level edge adds another. Every color is
18
+ * the fixed per-LEVEL identity color (`levelColor(depth)`), so a bar
19
+ * reads directly as "depended on by the service N levels up", identical
20
+ * in every path; column headers carry the parent level's color chip so
21
+ * bars map back to ancestors;
22
+ * - path highlight: rows covered by the closure (reachable along `to`) of
23
+ * the path's root get a faint background in the level-0 color;
24
+ * - rows without dependencies are dimmed and carry no chevron; a row
25
+ * already in the current path is marked ↩ (cycle guard — columns only
26
+ * ever open on click, so there is no auto-recursion).
27
+ *
28
+ * Pure React + Tailwind, no graph or layout library.
29
+ */
30
+ import type { UnitState } from '@moonshot-ai/agent-core-v2/_base/di/cascadeEngine';
31
+ import type { DebugGraph, DebugGraphNode } from '@moonshot-ai/agent-core-v2/debug/debugGraph';
32
+ import { useEffect, useMemo, useRef, useState } from 'react';
33
+
34
+ const STATE_COLORS: Record<UnitState | 'none', string> = {
35
+ Pending: '#a3a3a3',
36
+ Activating: '#38bdf8',
37
+ Active: '#34d399',
38
+ Unloading: '#fbbf24',
39
+ Failed: '#f87171',
40
+ none: '#525252',
41
+ };
42
+
43
+ /**
44
+ * Fixed identity color per path depth (level): `levelColor(d)` is the same
45
+ * for every service at depth d in every path, so a row's color bars read
46
+ * directly as "depended on by the service at level 0 / 1 / 2 …".
47
+ */
48
+ const LEVEL_PALETTE = [
49
+ '#f87171',
50
+ '#fb923c',
51
+ '#fbbf24',
52
+ '#a3e635',
53
+ '#34d399',
54
+ '#22d3ee',
55
+ '#38bdf8',
56
+ '#818cf8',
57
+ '#c084fc',
58
+ '#f472b6',
59
+ ];
60
+
61
+ function levelColor(depth: number): string {
62
+ return LEVEL_PALETTE[depth % LEVEL_PALETTE.length] ?? '#525252';
63
+ }
64
+
65
+ function scopeDepth(path: string): number {
66
+ return path.split('/').length;
67
+ }
68
+
69
+ function compareScopePaths(a: string, b: string): number {
70
+ return scopeDepth(a) - scopeDepth(b) || a.localeCompare(b);
71
+ }
72
+
73
+ function byToken(a: DebugGraphNode, b: DebugGraphNode): number {
74
+ return a.token.localeCompare(b.token);
75
+ }
76
+
77
+ function fallbackNode(id: string): DebugGraphNode {
78
+ return { id, token: id, scopePath: '' };
79
+ }
80
+
81
+ interface DepsColumn {
82
+ readonly title: string;
83
+ /**
84
+ * The scope the column's rows belong to by default ('' = unfiltered root
85
+ * column): rows from any other scope get a scopePath subtitle, as do all
86
+ * rows when the column spans more than one scope.
87
+ */
88
+ readonly homeScope: string;
89
+ readonly items: readonly DebugGraphNode[];
90
+ }
91
+
92
+ export function DiGraphPanel({ graph }: { graph: DebugGraph }) {
93
+ const [scopeFilter, setScopeFilter] = useState('');
94
+ /** Selected row id per column — path[i] is the row expanded into column i+1. */
95
+ const [path, setPath] = useState<readonly string[]>([]);
96
+ const scrollRef = useRef<HTMLDivElement>(null);
97
+
98
+ const allScopes = useMemo(
99
+ () => [...new Set(graph.nodes.map((n) => n.scopePath))].toSorted(compareScopePaths),
100
+ [graph],
101
+ );
102
+
103
+ const nodesById = useMemo(() => new Map(graph.nodes.map((n) => [n.id, n])), [graph]);
104
+
105
+ /** Direct dependencies per consumer id (edge `to` endpoints), deduped. */
106
+ const depsOf = useMemo(() => {
107
+ const acc = new Map<string, Set<string>>();
108
+ for (const e of graph.edges) {
109
+ const set = acc.get(e.from) ?? new Set<string>();
110
+ set.add(e.to);
111
+ acc.set(e.from, set);
112
+ }
113
+ return acc;
114
+ }, [graph]);
115
+
116
+ /** Services nothing consumes (never an edge's `to`), scope-filtered. */
117
+ const roots = useMemo(() => {
118
+ const consumed = new Set(graph.edges.map((e) => e.to));
119
+ const pool =
120
+ scopeFilter === '' ? graph.nodes : graph.nodes.filter((n) => n.scopePath === scopeFilter);
121
+ return pool.filter((n) => !consumed.has(n.id)).toSorted(byToken);
122
+ }, [graph, scopeFilter]);
123
+
124
+ // Walk the path, truncating it at the first entry that is no longer
125
+ // selectable (scope filter change or data refresh), so rendering and
126
+ // follow-up clicks always work from a valid prefix.
127
+ const { columns, usedPath } = useMemo(() => {
128
+ const columns: DepsColumn[] = [];
129
+ const usedPath: string[] = [];
130
+ columns.push({
131
+ title: `roots · ${scopeFilter === '' ? 'all scopes' : scopeFilter}`,
132
+ homeScope: scopeFilter,
133
+ items: roots,
134
+ });
135
+ let items = roots;
136
+ for (const id of path) {
137
+ if (!items.some((n) => n.id === id)) break;
138
+ usedPath.push(id);
139
+ const deps = [...(depsOf.get(id) ?? [])]
140
+ .map((depId) => nodesById.get(depId) ?? fallbackNode(depId))
141
+ .toSorted(byToken);
142
+ if (deps.length === 0) break;
143
+ const parent = nodesById.get(id);
144
+ columns.push({
145
+ title: parent?.token ?? id,
146
+ homeScope: parent?.scopePath ?? '',
147
+ items: deps,
148
+ });
149
+ items = deps;
150
+ }
151
+ return { columns, usedPath };
152
+ }, [scopeFilter, path, roots, depsOf, nodesById]);
153
+
154
+ // Keep the deepest column in view as the path grows.
155
+ useEffect(() => {
156
+ const el = scrollRef.current;
157
+ if (el !== null) el.scrollLeft = el.scrollWidth;
158
+ }, [columns.length]);
159
+
160
+ const select = (columnIndex: number, id: string) => {
161
+ setPath([...usedPath.slice(0, columnIndex), id]);
162
+ };
163
+
164
+ // Path context: the root the current path starts at (column 0 only holds
165
+ // roots, so this is always one — guarded anyway).
166
+ const pathRootId = usedPath[0];
167
+
168
+ // Path-highlight: nodes reachable (along `to`) from the path's root,
169
+ // tinted in the fixed level-0 color. Only the path root's closure is
170
+ // needed — computed lazily per selection.
171
+ const pathTint = pathRootId === undefined ? undefined : `${levelColor(0)}1a`;
172
+ const pathClosure = useMemo(() => {
173
+ if (pathRootId === undefined) return undefined;
174
+ const seen = new Set<string>();
175
+ const stack = [pathRootId];
176
+ while (stack.length > 0) {
177
+ const id = stack.pop() ?? '';
178
+ if (seen.has(id)) continue;
179
+ seen.add(id);
180
+ for (const dep of depsOf.get(id) ?? []) stack.push(dep);
181
+ }
182
+ return seen;
183
+ }, [pathRootId, depsOf]);
184
+
185
+ return (
186
+ <div>
187
+ <div className="mb-2 flex items-center gap-3">
188
+ <select
189
+ className="rounded border border-neutral-700 bg-neutral-950 px-2 py-1 font-mono text-[11px] text-neutral-100 outline-none focus:border-sky-600"
190
+ value={scopeFilter}
191
+ onChange={(e) => {
192
+ setScopeFilter(e.target.value);
193
+ }}
194
+ >
195
+ <option value="">all scopes</option>
196
+ {allScopes.map((scopePath) => (
197
+ <option key={scopePath} value={scopePath}>
198
+ {scopePath}
199
+ </option>
200
+ ))}
201
+ </select>
202
+ <span className="flex items-center gap-2 text-[10px] text-neutral-500">
203
+ {(Object.keys(STATE_COLORS) as (UnitState | 'none')[]).map((state) => (
204
+ <span key={state} className="flex items-center gap-1">
205
+ <span
206
+ className="inline-block h-2 w-2 rounded-full"
207
+ style={{ backgroundColor: STATE_COLORS[state] }}
208
+ />
209
+ {state === 'none' ? 'unseen' : state}
210
+ </span>
211
+ ))}
212
+ </span>
213
+ </div>
214
+ {graph.nodes.length === 0 ? (
215
+ <div className="text-[11px] text-neutral-600 italic">no nodes</div>
216
+ ) : (
217
+ <div
218
+ ref={scrollRef}
219
+ className="flex h-[calc(100vh-150px)] min-h-[320px] overflow-x-auto rounded-lg border border-neutral-800 bg-neutral-950"
220
+ >
221
+ {columns.map((column, columnIndex) => (
222
+ <DepsColumnView
223
+ key={columnIndex}
224
+ column={column}
225
+ columnIndex={columnIndex}
226
+ usedPath={usedPath}
227
+ depsOf={depsOf}
228
+ nodesById={nodesById}
229
+ pathTint={pathTint}
230
+ pathClosure={pathClosure}
231
+ onSelect={select}
232
+ />
233
+ ))}
234
+ </div>
235
+ )}
236
+ </div>
237
+ );
238
+ }
239
+
240
+ function DepsColumnView({
241
+ column,
242
+ columnIndex,
243
+ usedPath,
244
+ depsOf,
245
+ nodesById,
246
+ pathTint,
247
+ pathClosure,
248
+ onSelect,
249
+ }: {
250
+ column: DepsColumn;
251
+ columnIndex: number;
252
+ usedPath: readonly string[];
253
+ depsOf: ReadonlyMap<string, ReadonlySet<string>>;
254
+ nodesById: ReadonlyMap<string, DebugGraphNode>;
255
+ pathTint?: string;
256
+ pathClosure?: ReadonlySet<string>;
257
+ onSelect: (columnIndex: number, id: string) => void;
258
+ }) {
259
+ const mixedScopes = new Set(column.items.map((n) => n.scopePath)).size > 1;
260
+ const selectedId = usedPath[columnIndex];
261
+ return (
262
+ <div className="w-[230px] shrink-0 overflow-y-auto border-r border-neutral-800 last:border-r-0">
263
+ <div className="sticky top-0 flex items-baseline gap-2 border-b border-neutral-800 bg-neutral-950 px-2 py-1">
264
+ {columnIndex > 0 ? (
265
+ <span
266
+ className="h-2.5 w-2.5 shrink-0 self-center rounded-sm"
267
+ style={{ background: levelColor(columnIndex - 1) }}
268
+ title={`dependencies of ${column.title}`}
269
+ />
270
+ ) : null}
271
+ <span className="min-w-0 truncate font-mono text-[10px] font-semibold text-neutral-400">
272
+ {column.title}
273
+ </span>
274
+ <span className="ml-auto shrink-0 text-[9px] text-neutral-600">{column.items.length}</span>
275
+ </div>
276
+ <div className="p-1">
277
+ {column.items.length === 0 ? (
278
+ <div className="px-2 py-1 text-[10px] text-neutral-600 italic">
279
+ {columnIndex === 0 ? 'no roots' : 'no nodes'}
280
+ </div>
281
+ ) : (
282
+ column.items.map((node) => {
283
+ const hasDeps = (depsOf.get(node.id)?.size ?? 0) > 0;
284
+ const selected = node.id === selectedId;
285
+ const inPath = usedPath.slice(0, columnIndex).includes(node.id);
286
+ const showScope =
287
+ mixedScopes || (column.homeScope !== '' && node.scopePath !== column.homeScope);
288
+ // Path-scoped relation bars: column 0 rows carry the level-0
289
+ // color; expanded columns carry one bar per path ancestor with a
290
+ // DIRECT edge to this node, each in that ancestor's level color.
291
+ const bars: { color: string; label: string }[] = [];
292
+ if (columnIndex === 0) {
293
+ bars.push({ color: levelColor(0), label: node.token });
294
+ } else {
295
+ usedPath.slice(0, columnIndex).forEach((ancestorId, depth) => {
296
+ if (depsOf.get(ancestorId)?.has(node.id) === true) {
297
+ bars.push({
298
+ color: levelColor(depth),
299
+ label: nodesById.get(ancestorId)?.token ?? ancestorId,
300
+ });
301
+ }
302
+ });
303
+ }
304
+ const inPathTree = !selected && pathClosure?.has(node.id) === true;
305
+ const details = [
306
+ `state: ${node.state ?? 'unseen'}`,
307
+ node.uid !== undefined ? `#${node.uid}` : undefined,
308
+ inPath ? 'already in path' : undefined,
309
+ columnIndex > 0 && bars.length > 0
310
+ ? `direct dependency of: ${bars.map((b) => b.label).join(', ')}`
311
+ : undefined,
312
+ ]
313
+ .filter((s) => s !== undefined)
314
+ .join('\n');
315
+ return (
316
+ <button
317
+ key={node.id}
318
+ type="button"
319
+ title={`${node.id}\n${details}`}
320
+ onClick={() => {
321
+ onSelect(columnIndex, node.id);
322
+ }}
323
+ style={{
324
+ backgroundColor: inPathTree ? pathTint : undefined,
325
+ }}
326
+ className={`mb-0.5 flex w-full items-stretch gap-1.5 rounded px-1.5 py-0.5 text-left font-mono text-[11px] ${
327
+ selected
328
+ ? 'bg-neutral-800 text-sky-400'
329
+ : hasDeps
330
+ ? 'text-neutral-300 hover:bg-neutral-800/60'
331
+ : 'text-neutral-500 hover:bg-neutral-800/60'
332
+ }`}
333
+ >
334
+ <span className="flex shrink-0 gap-[2px] self-stretch">
335
+ {bars.map((bar, i) => (
336
+ <span
337
+ key={i}
338
+ className="w-[3px] rounded-sm"
339
+ style={{ background: bar.color }}
340
+ title={bar.label}
341
+ />
342
+ ))}
343
+ </span>
344
+ <span
345
+ className="mt-1 inline-block h-2 w-2 shrink-0 rounded-full"
346
+ style={{ backgroundColor: STATE_COLORS[node.state ?? 'none'] }}
347
+ />
348
+ <span className="min-w-0 flex-1 self-center">
349
+ <span className="block truncate">{node.token}</span>
350
+ {showScope ? (
351
+ <span className="block truncate text-[9px] text-neutral-600">
352
+ {node.scopePath}
353
+ </span>
354
+ ) : null}
355
+ </span>
356
+ {inPath ? (
357
+ <span
358
+ className="shrink-0 self-center text-[10px] text-amber-400"
359
+ title="already in path"
360
+ >
361
+ ↩
362
+ </span>
363
+ ) : null}
364
+ <span className="w-3 shrink-0 self-center text-right text-neutral-600">
365
+ {hasDeps ? '›' : ''}
366
+ </span>
367
+ </button>
368
+ );
369
+ })
370
+ )}
371
+ </div>
372
+ </div>
373
+ );
374
+ }
apps/kimi-inspect/src/components/methodArgs.test.ts ADDED
@@ -0,0 +1,124 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { buildArgs, parseParamFields, smartParse } from './methodArgs';
4
+
5
+ describe('parseParamFields', () => {
6
+ it('parses a single named parameter', () => {
7
+ expect(parseParamFields('sessionId')).toEqual([{ kind: 'value', name: 'sessionId' }]);
8
+ });
9
+
10
+ it('parses several named parameters', () => {
11
+ expect(parseParamFields('id, decision')).toEqual([
12
+ { kind: 'value', name: 'id' },
13
+ { kind: 'value', name: 'decision' },
14
+ ]);
15
+ });
16
+
17
+ it('keeps declared defaults', () => {
18
+ expect(parseParamFields("count = 1, mode = 'auto'")).toEqual([
19
+ { kind: 'value', name: 'count', defaultValue: '1' },
20
+ { kind: 'value', name: 'mode', defaultValue: "'auto'" },
21
+ ]);
22
+ });
23
+
24
+ it('does not split commas inside defaults', () => {
25
+ expect(parseParamFields("ids = ['a', 'b'], flag")).toEqual([
26
+ { kind: 'value', name: 'ids', defaultValue: "['a', 'b']" },
27
+ { kind: 'value', name: 'flag' },
28
+ ]);
29
+ });
30
+
31
+ it('expands a destructured object parameter into keys', () => {
32
+ expect(parseParamFields('{ workspaceId, limit }')).toEqual([
33
+ { kind: 'object', name: '{ workspaceId, limit }', keys: ['workspaceId', 'limit'] },
34
+ ]);
35
+ });
36
+
37
+ it('strips key defaults and renames in object patterns', () => {
38
+ expect(parseParamFields('{ limit = 10, workspaceId: ws }')).toEqual([
39
+ {
40
+ kind: 'object',
41
+ name: '{ limit = 10, workspaceId: ws }',
42
+ keys: ['limit', 'workspaceId'],
43
+ },
44
+ ]);
45
+ });
46
+
47
+ it('keeps the object-pattern default', () => {
48
+ expect(parseParamFields('{ a, b } = {}')).toEqual([
49
+ { kind: 'object', name: '{ a, b } = {}', keys: ['a', 'b'], defaultValue: '{}' },
50
+ ]);
51
+ });
52
+
53
+ it('drops rest elements inside object patterns', () => {
54
+ expect(parseParamFields('{ a, ...rest }')).toEqual([
55
+ { kind: 'object', name: '{ a, ...rest }', keys: ['a'] },
56
+ ]);
57
+ });
58
+
59
+ it('falls back to raw for rest and array patterns', () => {
60
+ expect(parseParamFields('...args')).toEqual([{ kind: 'raw', label: '...args' }]);
61
+ expect(parseParamFields('[a, b]')).toEqual([{ kind: 'raw', label: '[a, b]' }]);
62
+ });
63
+
64
+ it('returns no fields for empty params', () => {
65
+ expect(parseParamFields('')).toEqual([]);
66
+ });
67
+ });
68
+
69
+ describe('smartParse', () => {
70
+ it('parses JSON values', () => {
71
+ expect(smartParse('5')).toBe(5);
72
+ expect(smartParse('true')).toBe(true);
73
+ expect(smartParse('{"a":1}')).toEqual({ a: 1 });
74
+ expect(smartParse('"x"')).toBe('x');
75
+ });
76
+
77
+ it('passes non-JSON through as a plain string', () => {
78
+ expect(smartParse('main')).toBe('main');
79
+ expect(smartParse('k2-thinking')).toBe('k2-thinking');
80
+ });
81
+ });
82
+
83
+ describe('buildArgs', () => {
84
+ it('builds a single argument from a value field', () => {
85
+ const fields = parseParamFields('sessionId');
86
+ expect(buildArgs(fields, { '0': 'abc' })).toEqual(['abc']);
87
+ });
88
+
89
+ it('assembles a destructured object from its key fields', () => {
90
+ const fields = parseParamFields('{ workspaceId, limit }');
91
+ expect(buildArgs(fields, { '0.workspaceId': 'ws1', '0.limit': '5' })).toEqual([
92
+ { workspaceId: 'ws1', limit: 5 },
93
+ ]);
94
+ });
95
+
96
+ it('drops empty keys and omits a fully-empty object', () => {
97
+ const fields = parseParamFields('{ workspaceId, limit }');
98
+ expect(buildArgs(fields, { '0.limit': '5' })).toEqual([{ limit: 5 }]);
99
+ expect(buildArgs(fields, {})).toEqual([]);
100
+ });
101
+
102
+ it('uses declared defaults for empty value fields', () => {
103
+ const fields = parseParamFields("count = 1, mode = 'auto'");
104
+ expect(buildArgs(fields, {})).toEqual([1, 'auto']);
105
+ expect(buildArgs(fields, { '1': 'yolo' })).toEqual([1, 'yolo']);
106
+ });
107
+
108
+ it('truncates trailing empty params but keeps interior holes', () => {
109
+ const fields = parseParamFields('a, b, c');
110
+ expect(buildArgs(fields, { '0': 'x' })).toEqual(['x']);
111
+ // Interior hole: `b` empty, `c` filled — the hole serializes as null.
112
+ expect(JSON.parse(JSON.stringify(buildArgs(fields, { '0': 'x', '2': 'z' })))).toEqual([
113
+ 'x',
114
+ null,
115
+ 'z',
116
+ ]);
117
+ });
118
+
119
+ it('parses raw fallback fields as strict JSON', () => {
120
+ const fields = parseParamFields('...args');
121
+ expect(buildArgs(fields, { '0': '[1, 2]' })).toEqual([[1, 2]]);
122
+ expect(() => buildArgs(fields, { '0': 'not json' })).toThrow();
123
+ });
124
+ });
apps/kimi-inspect/src/components/methodArgs.ts ADDED
@@ -0,0 +1,198 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Structured method-argument editing for the dynamic Service cards, driven
3
+ * only by the wire `params` string (the declared parameter list parsed from
4
+ * `Function#toString` server-side — names only, no per-method schemas).
5
+ *
6
+ * `parseParamFields` splits the params string into editable fields:
7
+ * - a named parameter (`title`, `count = 1`) becomes one input; an empty
8
+ * input falls back to the declared default;
9
+ * - a destructured object parameter (`{ workspaceId, limit }`) becomes one
10
+ * input per key, assembled back into an object (empty keys are dropped);
11
+ * - anything unparseable (rest params, array patterns) falls back to one
12
+ * raw JSON input — the old behavior.
13
+ *
14
+ * Field values are smart-parsed (`smartParse`): text that parses as JSON is
15
+ * passed through parsed (numbers, booleans, objects, quoted strings),
16
+ * anything else goes as a plain string — so `main` needs no quotes while
17
+ * `{ "a": 1 }` still works.
18
+ */
19
+
20
+ export type ParamField =
21
+ | { readonly kind: 'value'; readonly name: string; readonly defaultValue?: string }
22
+ | {
23
+ readonly kind: 'object';
24
+ /** Original pattern text, used as the group label (e.g. `{ workspaceId, limit }`). */
25
+ readonly name: string;
26
+ readonly keys: readonly string[];
27
+ readonly defaultValue?: string;
28
+ }
29
+ | { readonly kind: 'raw'; readonly label: string };
30
+
31
+ /** Input state key for a field: the param index, or `index.key` for object keys. */
32
+ export function fieldKey(index: number, key?: string): string {
33
+ return key === undefined ? String(index) : `${index}.${key}`;
34
+ }
35
+
36
+ /**
37
+ * Split a parameter list at top-level commas, tracking `(){}[]` nesting and
38
+ * string literals (defaults can contain commas, e.g. `= ['a', 'b']`).
39
+ */
40
+ function splitTopLevel(src: string): string[] {
41
+ const parts: string[] = [];
42
+ let depth = 0;
43
+ let quote: string | null = null;
44
+ let start = 0;
45
+ for (let i = 0; i < src.length; i++) {
46
+ const ch = src[i]!;
47
+ if (quote !== null) {
48
+ if (ch === '\\') i++;
49
+ else if (ch === quote) quote = null;
50
+ continue;
51
+ }
52
+ if (ch === "'" || ch === '"' || ch === '`') quote = ch;
53
+ else if (ch === '(' || ch === '{' || ch === '[') depth++;
54
+ else if (ch === ')' || ch === '}' || ch === ']') depth--;
55
+ else if (ch === ',' && depth === 0) {
56
+ parts.push(src.slice(start, i));
57
+ start = i + 1;
58
+ }
59
+ }
60
+ parts.push(src.slice(start));
61
+ return parts;
62
+ }
63
+
64
+ /** Index of the first `=` at nesting depth 0 (skipping `=>`, `==`, `===`), or -1. */
65
+ function indexOfTopLevelEquals(src: string): number {
66
+ let depth = 0;
67
+ let quote: string | null = null;
68
+ for (let i = 0; i < src.length; i++) {
69
+ const ch = src[i]!;
70
+ if (quote !== null) {
71
+ if (ch === '\\') i++;
72
+ else if (ch === quote) quote = null;
73
+ continue;
74
+ }
75
+ if (ch === "'" || ch === '"' || ch === '`') quote = ch;
76
+ else if (ch === '(' || ch === '{' || ch === '[') depth++;
77
+ else if (ch === ')' || ch === '}' || ch === ']') depth--;
78
+ else if (ch === '=' && depth === 0 && src[i + 1] !== '=' && src[i + 1] !== '>') return i;
79
+ }
80
+ return -1;
81
+ }
82
+
83
+ /** Index of the `}` matching the `{` at position 0 of `src`, or -1. */
84
+ function matchingBrace(src: string): number {
85
+ let depth = 0;
86
+ let quote: string | null = null;
87
+ for (let i = 0; i < src.length; i++) {
88
+ const ch = src[i]!;
89
+ if (quote !== null) {
90
+ if (ch === '\\') i++;
91
+ else if (ch === quote) quote = null;
92
+ continue;
93
+ }
94
+ if (ch === "'" || ch === '"' || ch === '`') quote = ch;
95
+ else if (ch === '{') depth++;
96
+ else if (ch === '}') {
97
+ depth--;
98
+ if (depth === 0) return i;
99
+ }
100
+ }
101
+ return -1;
102
+ }
103
+
104
+ /** Property name out of one object-pattern entry: `a`, `a: b`, `a = 1`, `a: b = 1` → `a`. */
105
+ function objectPatternKey(entry: string): string {
106
+ const colon = entry.indexOf(':');
107
+ const eq = indexOfTopLevelEquals(entry);
108
+ let end = entry.length;
109
+ if (colon !== -1) end = Math.min(end, colon);
110
+ if (eq !== -1) end = Math.min(end, eq);
111
+ return entry.slice(0, end).trim();
112
+ }
113
+
114
+ /** Parse the wire `params` string into editable fields (see file header). */
115
+ export function parseParamFields(params: string): readonly ParamField[] {
116
+ return splitTopLevel(params)
117
+ .map((s) => s.trim())
118
+ .filter((s) => s !== '')
119
+ .map((part): ParamField => {
120
+ if (part.startsWith('{')) {
121
+ const close = matchingBrace(part);
122
+ if (close === -1) return { kind: 'raw', label: part };
123
+ const keys = splitTopLevel(part.slice(1, close))
124
+ .map((k) => objectPatternKey(k.trim()))
125
+ .filter((k) => k !== '' && !k.startsWith('...'));
126
+ const rest = part.slice(close + 1).trim();
127
+ const defaultValue = rest.startsWith('=') ? rest.slice(1).trim() : undefined;
128
+ if (keys.length === 0) return { kind: 'raw', label: part };
129
+ return { kind: 'object', name: part, keys, defaultValue };
130
+ }
131
+ if (part.startsWith('[') || part.startsWith('...')) {
132
+ return { kind: 'raw', label: part };
133
+ }
134
+ const eq = indexOfTopLevelEquals(part);
135
+ if (eq !== -1) {
136
+ const name = part.slice(0, eq).trim();
137
+ const defaultValue = part.slice(eq + 1).trim();
138
+ if (/^[A-Za-z_$][\w$]*$/.test(name)) return { kind: 'value', name, defaultValue };
139
+ return { kind: 'raw', label: part };
140
+ }
141
+ if (/^[A-Za-z_$][\w$]*$/.test(part)) return { kind: 'value', name: part };
142
+ return { kind: 'raw', label: part };
143
+ });
144
+ }
145
+
146
+ /** Smart-parse one field value: JSON when it parses, the raw string otherwise. */
147
+ export function smartParse(raw: string): unknown {
148
+ try {
149
+ return JSON.parse(raw);
150
+ } catch {
151
+ // Declared defaults are source text, where strings use JS single-quote
152
+ // literals ('auto') that JSON.parse rejects — unquote those.
153
+ if (raw.length >= 2 && raw.startsWith("'") && raw.endsWith("'")) {
154
+ return raw.slice(1, -1).replaceAll(/\\(.)/g, '$1');
155
+ }
156
+ return raw;
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Assemble the wire argument array from field inputs (keyed by `fieldKey`).
162
+ * Empty value fields fall back to their declared default; empty object
163
+ * fields drop the key, and a fully-empty object param is omitted. Trailing
164
+ * holes are truncated; interior holes serialize as `null` on the wire.
165
+ * Throws when a raw field contains invalid JSON.
166
+ */
167
+ export function buildArgs(
168
+ fields: readonly ParamField[],
169
+ values: Readonly<Record<string, string>>,
170
+ ): unknown[] {
171
+ const args: unknown[] = [];
172
+ fields.forEach((field, i) => {
173
+ if (field.kind === 'value') {
174
+ const raw = (values[fieldKey(i)] ?? '').trim();
175
+ if (raw !== '') args[i] = smartParse(raw);
176
+ else if (field.defaultValue !== undefined) args[i] = smartParse(field.defaultValue);
177
+ else args[i] = undefined;
178
+ } else if (field.kind === 'object') {
179
+ const obj: Record<string, unknown> = {};
180
+ let filled = false;
181
+ for (const key of field.keys) {
182
+ const raw = (values[fieldKey(i, key)] ?? '').trim();
183
+ if (raw !== '') {
184
+ obj[key] = smartParse(raw);
185
+ filled = true;
186
+ }
187
+ }
188
+ args[i] = filled ? obj : undefined;
189
+ } else {
190
+ const raw = (values[fieldKey(i)] ?? '').trim();
191
+ if (raw !== '') args[i] = JSON.parse(raw);
192
+ else args[i] = undefined;
193
+ }
194
+ });
195
+ let end = args.length;
196
+ while (end > 0 && args[end - 1] === undefined) end--;
197
+ return args.slice(0, end);
198
+ }
apps/kimi-inspect/src/connection.tsx ADDED
@@ -0,0 +1,364 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Connection context — owns the inspect client (HTTP calls over the
3
+ * `/api/v1/debug` surface) built from the selected server URL + token.
4
+ *
5
+ * Three ways to connect:
6
+ * 1. deep link `?url=` / `?token=` or a previously saved manual config
7
+ * (persisted in localStorage);
8
+ * 2. zero-config discovery: on startup the dev middleware
9
+ * (`/__inspect/servers`) lists every local kap-server with the home
10
+ * token — the app connects straight to the remembered / proxy / first
11
+ * instance, persisting nothing but the picked URL (so a reload re-picks
12
+ * it while it is still alive, and never resurrects a stale port);
13
+ * 3. the manual form or a discovered-server card on the connect screen.
14
+ * `disconnect` suppresses the discovery bootstrap for the rest of the
15
+ * session so it lands on the connect screen instead of reconnecting.
16
+ *
17
+ * The client is built only after `probeDebugSurface` confirms the server
18
+ * mounts `/api/v1/debug` (kap-server started with `--debug-endpoints` on a
19
+ * loopback bind). There is no fallback surface — when the probe fails the
20
+ * connection stops on a dedicated error screen (with retry / reconfigure)
21
+ * instead of silently degrading.
22
+ */
23
+
24
+ import {
25
+ createContext,
26
+ useCallback,
27
+ useContext,
28
+ useEffect,
29
+ useMemo,
30
+ useState,
31
+ type ReactNode,
32
+ } from 'react';
33
+
34
+ import { createInspectClient, probeDebugSurface, type InspectClient } from './channel';
35
+ import { fetchServerDiscovery, pickDefaultServer, useServerDiscovery } from './servers';
36
+
37
+ export interface ConnectionConfig {
38
+ /** Server base URL; empty string means same-origin (the Vite dev proxy). */
39
+ readonly url: string;
40
+ readonly token: string;
41
+ }
42
+
43
+ export interface ConnectOptions {
44
+ /** Persist the whole config (manual form / deep link). Default `true`;
45
+ * discovered connects pass `false` so a reload re-discovers fresh state. */
46
+ readonly persist?: boolean;
47
+ /** Remember just this URL as the preferred discovery pick (`kimi-inspect.server-url`). */
48
+ readonly rememberServerUrl?: string;
49
+ }
50
+
51
+ const STORAGE_KEY = 'kimi-inspect.connection';
52
+ const REMEMBERED_SERVER_KEY = 'kimi-inspect.server-url';
53
+
54
+ function readInitialConfig(): ConnectionConfig {
55
+ const params = new URLSearchParams(window.location.search);
56
+ const qUrl = params.get('url');
57
+ const qToken = params.get('token');
58
+ if (qUrl !== null || qToken !== null) {
59
+ return { url: qUrl ?? '', token: qToken ?? '' };
60
+ }
61
+ try {
62
+ const raw = localStorage.getItem(STORAGE_KEY);
63
+ if (raw !== null) {
64
+ const parsed = JSON.parse(raw) as ConnectionConfig;
65
+ return { url: parsed.url ?? '', token: parsed.token ?? '' };
66
+ }
67
+ } catch {
68
+ // corrupt storage — fall through to default
69
+ }
70
+ return { url: '', token: '' };
71
+ }
72
+
73
+ /** Resolve the configured (possibly relative) URL to an absolute base for the client. */
74
+ export function resolveBaseUrl(url: string): string {
75
+ const trimmed = url.trim().replace(/\/$/, '');
76
+ if (trimmed === '') return window.location.origin;
77
+ return trimmed;
78
+ }
79
+
80
+ interface ConnectionValue {
81
+ readonly config: ConnectionConfig;
82
+ readonly baseUrl: string;
83
+ readonly klient: InspectClient;
84
+ readonly connect: (config: ConnectionConfig, opts?: ConnectOptions) => void;
85
+ readonly disconnect: () => void;
86
+ }
87
+
88
+ const ConnectionContext = createContext<ConnectionValue | null>(null);
89
+
90
+ export function ConnectionProvider({ children }: { children: ReactNode }) {
91
+ const [config, setConfig] = useState<ConnectionConfig | null>(() => {
92
+ const initial = readInitialConfig();
93
+ // Auto-connect only when the user explicitly connected before (stored) or
94
+ // deep-linked (query). First visit goes through the discovery bootstrap.
95
+ const params = new URLSearchParams(window.location.search);
96
+ if (params.has('url') || params.has('token')) return initial;
97
+ return localStorage.getItem(STORAGE_KEY) !== null ? initial : null;
98
+ });
99
+ const [discovering, setDiscovering] = useState(config === null);
100
+ const [suppressDiscovery, setSuppressDiscovery] = useState(false);
101
+
102
+ // Discovery bootstrap: with nothing explicit configured, scan the local
103
+ // kap-server instance registry (via the dev middleware) and auto-connect.
104
+ useEffect(() => {
105
+ if (config !== null || suppressDiscovery) return;
106
+ let cancelled = false;
107
+ setDiscovering(true);
108
+ void fetchServerDiscovery().then((discovery) => {
109
+ if (cancelled) return;
110
+ setDiscovering(false);
111
+ if (discovery === null) return;
112
+ const pick = pickDefaultServer(discovery, localStorage.getItem(REMEMBERED_SERVER_KEY));
113
+ if (pick === undefined) return;
114
+ setConfig({ url: pick.url, token: discovery.token ?? '' });
115
+ });
116
+ return () => {
117
+ cancelled = true;
118
+ };
119
+ }, [config, suppressDiscovery]);
120
+
121
+ // Debug-surface probe: the app talks ONLY to `/api/v1/debug` (kap-server
122
+ // `--debug-endpoints`, loopback). The client is built once the probe for
123
+ // this exact config has succeeded; a failure is kept and rendered as a
124
+ // blocking error screen (no fallback surface exists anymore).
125
+ const [probe, setProbe] = useState<{
126
+ readonly key: string;
127
+ readonly error: string | null;
128
+ } | null>(null);
129
+ const [probeNonce, setProbeNonce] = useState(0);
130
+ const configKey = config === null ? null : `${resolveBaseUrl(config.url)}|${config.token.trim()}`;
131
+
132
+ useEffect(() => {
133
+ if (config === null || configKey === null) {
134
+ setProbe(null);
135
+ return;
136
+ }
137
+ let cancelled = false;
138
+ const token = config.token.trim();
139
+ void probeDebugSurface({
140
+ baseUrl: resolveBaseUrl(config.url),
141
+ token: token === '' ? undefined : token,
142
+ }).then(
143
+ () => {
144
+ if (!cancelled) setProbe({ key: configKey, error: null });
145
+ },
146
+ (error: unknown) => {
147
+ if (cancelled) return;
148
+ const message = error instanceof Error ? error.message : String(error);
149
+ setProbe({ key: configKey, error: message });
150
+ },
151
+ );
152
+ return () => {
153
+ cancelled = true;
154
+ };
155
+ }, [config, configKey, probeNonce]);
156
+
157
+ const klient = useMemo(() => {
158
+ if (config === null || configKey === null) return null;
159
+ if (probe === null || probe.key !== configKey || probe.error !== null) return null;
160
+ const token = config.token.trim();
161
+ return createInspectClient({
162
+ url: resolveBaseUrl(config.url),
163
+ token: token === '' ? undefined : token,
164
+ });
165
+ }, [config, configKey, probe]);
166
+
167
+ const connect = useCallback((next: ConnectionConfig, opts: ConnectOptions = {}) => {
168
+ if (opts.persist ?? true) {
169
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
170
+ } else {
171
+ localStorage.removeItem(STORAGE_KEY);
172
+ }
173
+ if (opts.rememberServerUrl !== undefined) {
174
+ localStorage.setItem(REMEMBERED_SERVER_KEY, opts.rememberServerUrl);
175
+ }
176
+ setSuppressDiscovery(false);
177
+ setConfig(next);
178
+ }, []);
179
+
180
+ const disconnect = useCallback(() => {
181
+ localStorage.removeItem(STORAGE_KEY);
182
+ localStorage.removeItem(REMEMBERED_SERVER_KEY);
183
+ setSuppressDiscovery(true);
184
+ setConfig(null);
185
+ }, []);
186
+
187
+ const retryProbe = useCallback(() => {
188
+ setProbe(null);
189
+ setProbeNonce((n) => n + 1);
190
+ }, []);
191
+
192
+ const value = useMemo<ConnectionValue | null>(() => {
193
+ if (klient === null || config === null) return null;
194
+ return { config, baseUrl: resolveBaseUrl(config.url), klient, connect, disconnect };
195
+ }, [klient, config, connect, disconnect]);
196
+
197
+ const probeFailure =
198
+ config !== null && probe !== null && probe.key === configKey && probe.error !== null
199
+ ? { baseUrl: resolveBaseUrl(config.url), error: probe.error }
200
+ : null;
201
+
202
+ return (
203
+ <ConnectionContext.Provider value={value}>
204
+ {value !== null ? (
205
+ children
206
+ ) : probeFailure !== null ? (
207
+ <DebugSurfaceError
208
+ baseUrl={probeFailure.baseUrl}
209
+ error={probeFailure.error}
210
+ onRetry={retryProbe}
211
+ onBack={disconnect}
212
+ />
213
+ ) : config !== null ? (
214
+ <div className="flex h-screen items-center justify-center">
215
+ <div className="text-sm text-neutral-500">
216
+ Connecting to {resolveBaseUrl(config.url)}…
217
+ </div>
218
+ </div>
219
+ ) : discovering && !suppressDiscovery ? (
220
+ <div className="flex h-screen items-center justify-center">
221
+ <div className="text-sm text-neutral-500">Discovering local kap-servers…</div>
222
+ </div>
223
+ ) : (
224
+ <ConnectScreen onConnect={connect} initial={readInitialConfig()} />
225
+ )}
226
+ </ConnectionContext.Provider>
227
+ );
228
+ }
229
+
230
+ export function useConnection(): ConnectionValue {
231
+ const value = useContext(ConnectionContext);
232
+ if (value === null) {
233
+ throw new Error('useConnection used before connecting');
234
+ }
235
+ return value;
236
+ }
237
+
238
+ /** Blocking screen when the server does not mount the debug RPC surface. */
239
+ function DebugSurfaceError({
240
+ baseUrl,
241
+ error,
242
+ onRetry,
243
+ onBack,
244
+ }: {
245
+ baseUrl: string;
246
+ error: string;
247
+ onRetry: () => void;
248
+ onBack: () => void;
249
+ }) {
250
+ return (
251
+ <div className="flex h-screen items-center justify-center">
252
+ <div className="w-[520px] rounded-lg border border-red-900/60 bg-neutral-900 p-6 shadow-xl">
253
+ <h1 className="mb-1 text-lg font-semibold text-red-300">Debug surface unavailable</h1>
254
+ <p className="mb-3 text-xs leading-relaxed text-neutral-400">
255
+ Kimi Inspect talks to kap-server exclusively over the debug RPC surface (
256
+ <code className="text-neutral-300">/api/v1/debug</code>), and{' '}
257
+ <code className="text-neutral-300">{baseUrl}</code> does not serve it. Start kap-server
258
+ with <code className="text-neutral-300">--debug-endpoints</code> on a loopback bind and
259
+ retry.
260
+ </p>
261
+ <div className="mb-4 rounded bg-red-950/50 px-2 py-1.5 font-mono text-[11px] text-red-400">
262
+ {error}
263
+ </div>
264
+ <div className="flex gap-2">
265
+ <button
266
+ className="rounded bg-sky-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-sky-500"
267
+ onClick={onRetry}
268
+ >
269
+ Retry
270
+ </button>
271
+ <button
272
+ className="rounded border border-neutral-700 px-3 py-1.5 text-sm text-neutral-300 hover:bg-neutral-800"
273
+ onClick={onBack}
274
+ >
275
+ Change server
276
+ </button>
277
+ </div>
278
+ </div>
279
+ </div>
280
+ );
281
+ }
282
+
283
+ function ConnectScreen({
284
+ onConnect,
285
+ initial,
286
+ }: {
287
+ onConnect: (config: ConnectionConfig, opts?: ConnectOptions) => void;
288
+ initial: ConnectionConfig;
289
+ }) {
290
+ const [url, setUrl] = useState(initial.url);
291
+ const [token, setToken] = useState(initial.token);
292
+ const discovery = useServerDiscovery();
293
+ const servers = discovery.data?.servers ?? [];
294
+ return (
295
+ <div className="flex h-screen items-center justify-center">
296
+ <form
297
+ className="w-[420px] rounded-lg border border-neutral-800 bg-neutral-900 p-6 shadow-xl"
298
+ onSubmit={(e) => {
299
+ e.preventDefault();
300
+ onConnect({ url, token });
301
+ }}
302
+ >
303
+ <h1 className="mb-1 text-lg font-semibold text-neutral-100">Kimi Inspect</h1>
304
+ <p className="mb-5 text-xs text-neutral-500">
305
+ Connect to a kap-server started with{' '}
306
+ <code className="text-neutral-400">--debug-endpoints</code> (
307
+ <code className="text-neutral-400">/api/v1/debug</code>). Leave the URL empty to use the
308
+ same-origin dev proxy
309
+ {` (${__KIMI_INSPECT_PROXY_TARGET__})`}.
310
+ </p>
311
+ {servers.length > 0 ? (
312
+ <div className="mb-5">
313
+ <div className="mb-1 text-xs text-neutral-400">
314
+ Discovered on this machine{discovery.data?.home ? ` (${discovery.data.home})` : ''}
315
+ </div>
316
+ <div className="space-y-1.5">
317
+ {servers.map((s) => (
318
+ <button
319
+ key={s.id}
320
+ type="button"
321
+ className="flex w-full items-center gap-2 rounded border border-neutral-700 bg-neutral-950 px-3 py-2 text-left text-[12px] text-neutral-200 hover:border-sky-600"
322
+ onClick={() =>
323
+ onConnect(
324
+ { url: s.url, token: discovery.data?.token ?? '' },
325
+ { persist: false, rememberServerUrl: s.url },
326
+ )
327
+ }
328
+ >
329
+ <span className="font-mono">{s.url.replace(/^https?:\/\//, '')}</span>
330
+ {s.pid !== undefined ? (
331
+ <span className="text-[10px] text-neutral-500">pid {s.pid}</span>
332
+ ) : null}
333
+ <span className="ml-auto text-[10px] uppercase text-neutral-600">{s.source}</span>
334
+ </button>
335
+ ))}
336
+ </div>
337
+ </div>
338
+ ) : null}
339
+ <label className="mb-1 block text-xs text-neutral-400">Server URL</label>
340
+ <input
341
+ className="mb-4 w-full rounded border border-neutral-700 bg-neutral-950 px-3 py-2 text-sm text-neutral-100 outline-none focus:border-sky-600"
342
+ placeholder="http://127.0.0.1:58627 (empty = dev proxy)"
343
+ value={url}
344
+ onChange={(e) => setUrl(e.target.value)}
345
+ />
346
+ <label className="mb-1 block text-xs text-neutral-400">Bearer token (optional)</label>
347
+ <input
348
+ className="mb-5 w-full rounded border border-neutral-700 bg-neutral-950 px-3 py-2 text-sm text-neutral-100 outline-none focus:border-sky-600"
349
+ placeholder="~/.kimi-code/server.token"
350
+ value={token}
351
+ onChange={(e) => setToken(e.target.value)}
352
+ />
353
+ <button
354
+ type="submit"
355
+ className="w-full rounded bg-sky-600 px-3 py-2 text-sm font-medium text-white hover:bg-sky-500"
356
+ >
357
+ Connect
358
+ </button>
359
+ </form>
360
+ </div>
361
+ );
362
+ }
363
+
364
+ declare const __KIMI_INSPECT_PROXY_TARGET__: string;
apps/kimi-inspect/src/fs/api.test.ts ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { fetchFsSuggest } from './api';
4
+
5
+ function okEnvelope(data: unknown) {
6
+ return { code: 0, msg: 'success', data, request_id: 'r1' };
7
+ }
8
+
9
+ function fakeFetch(envelope: unknown) {
10
+ const calls: { url: string; init?: RequestInit }[] = [];
11
+ const fetchImpl = (async (url: string | URL, init?: RequestInit) => {
12
+ calls.push({ url: String(url), init });
13
+ return { json: async () => envelope };
14
+ }) as unknown as typeof fetch;
15
+ return { calls, fetchImpl };
16
+ }
17
+
18
+ const resultData = {
19
+ items: [
20
+ {
21
+ path: 'apps/desktop',
22
+ name: 'desktop',
23
+ kind: 'directory',
24
+ score: 0.9,
25
+ match_positions: [5, 6],
26
+ },
27
+ { path: 'README.md', name: 'README.md', kind: 'file', score: 0.8, match_positions: [0, 1] },
28
+ { path: 'broken' },
29
+ ],
30
+ truncated: true,
31
+ };
32
+
33
+ describe('fetchFsSuggest', () => {
34
+ it('posts the roots suggestion request and maps items', async () => {
35
+ const { calls, fetchImpl } = fakeFetch(okEnvelope(resultData));
36
+ const result = await fetchFsSuggest({
37
+ baseUrl: 'http://h:1/',
38
+ token: 'tok',
39
+ roots: ['/repo', '/extra'],
40
+ query: 'apps/de',
41
+ limit: 20,
42
+ followGitignore: false,
43
+ showHidden: true,
44
+ includeGlobs: ['**/*.ts'],
45
+ excludeGlobs: ['dist/**'],
46
+ runtimeId: 'local',
47
+ fetchImpl,
48
+ });
49
+
50
+ expect(calls[0]!.url).toBe('http://h:1/api/v1/fs:suggest');
51
+ expect(calls[0]!.init?.method).toBe('POST');
52
+ expect(calls[0]!.init?.headers).toEqual({
53
+ 'content-type': 'application/json',
54
+ authorization: 'Bearer tok',
55
+ });
56
+ expect(JSON.parse(calls[0]!.init?.body as string)).toEqual({
57
+ roots: ['/repo', '/extra'],
58
+ query: 'apps/de',
59
+ limit: 20,
60
+ follow_gitignore: false,
61
+ show_hidden: true,
62
+ include_globs: ['**/*.ts'],
63
+ exclude_globs: ['dist/**'],
64
+ runtime_id: 'local',
65
+ });
66
+ expect(result.items).toHaveLength(2);
67
+ expect(result.items[0]).toEqual({
68
+ path: 'apps/desktop',
69
+ name: 'desktop',
70
+ kind: 'directory',
71
+ score: 0.9,
72
+ matchPositions: [5, 6],
73
+ });
74
+ expect(result.truncated).toBe(true);
75
+ });
76
+
77
+ it('omits optional fields and authorization when not configured', async () => {
78
+ const { calls, fetchImpl } = fakeFetch(okEnvelope({ items: [], truncated: false }));
79
+ await fetchFsSuggest({ baseUrl: 'http://h:1', roots: ['/repo'], query: '', fetchImpl });
80
+ expect(calls[0]!.init?.headers).toEqual({ 'content-type': 'application/json' });
81
+ expect(JSON.parse(calls[0]!.init?.body as string)).toEqual({
82
+ roots: ['/repo'],
83
+ query: '',
84
+ });
85
+ });
86
+
87
+ it('throws on a non-zero envelope code', async () => {
88
+ const { fetchImpl } = fakeFetch({ code: 40409, msg: 'root missing', data: null });
89
+ await expect(
90
+ fetchFsSuggest({ baseUrl: 'http://h:1', roots: ['/missing'], query: 'x', fetchImpl }),
91
+ ).rejects.toThrow(/40409/);
92
+ });
93
+
94
+ it('throws on a malformed payload', async () => {
95
+ const { fetchImpl } = fakeFetch(okEnvelope({ truncated: false }));
96
+ await expect(
97
+ fetchFsSuggest({ baseUrl: 'http://h:1', roots: ['/repo'], query: 'x', fetchImpl }),
98
+ ).rejects.toThrow(/unexpected response shape/);
99
+ });
100
+ });
apps/kimi-inspect/src/fs/api.ts ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export type FsSuggestKind = 'file' | 'directory' | 'symlink';
2
+
3
+ export interface FsSuggestItem {
4
+ readonly path: string;
5
+ readonly name: string;
6
+ readonly kind: FsSuggestKind;
7
+ readonly score: number;
8
+ readonly matchPositions: readonly number[];
9
+ }
10
+
11
+ export interface FsSuggestResult {
12
+ readonly items: readonly FsSuggestItem[];
13
+ readonly truncated: boolean;
14
+ }
15
+
16
+ export interface FetchFsSuggestOptions {
17
+ readonly baseUrl: string;
18
+ readonly token?: string;
19
+ readonly roots: readonly string[];
20
+ readonly query: string;
21
+ readonly limit?: number;
22
+ readonly followGitignore?: boolean;
23
+ readonly showHidden?: boolean;
24
+ readonly includeGlobs?: readonly string[];
25
+ readonly excludeGlobs?: readonly string[];
26
+ readonly runtimeId?: string;
27
+ readonly fetchImpl?: typeof fetch;
28
+ }
29
+
30
+ const KINDS = new Set<FsSuggestKind>(['file', 'directory', 'symlink']);
31
+
32
+ function parseItem(value: unknown): FsSuggestItem | undefined {
33
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return undefined;
34
+ const item = value as Record<string, unknown>;
35
+ if (
36
+ typeof item['path'] !== 'string' ||
37
+ typeof item['name'] !== 'string' ||
38
+ typeof item['kind'] !== 'string' ||
39
+ !KINDS.has(item['kind'] as FsSuggestKind) ||
40
+ typeof item['score'] !== 'number' ||
41
+ !Array.isArray(item['match_positions']) ||
42
+ !item['match_positions'].every((position) => typeof position === 'number')
43
+ ) {
44
+ return undefined;
45
+ }
46
+ return {
47
+ path: item['path'],
48
+ name: item['name'],
49
+ kind: item['kind'] as FsSuggestKind,
50
+ score: item['score'],
51
+ matchPositions: item['match_positions'] as number[],
52
+ };
53
+ }
54
+
55
+ async function postSuggest(
56
+ body: Record<string, unknown>,
57
+ opts: { baseUrl: string; token?: string; fetchImpl?: typeof fetch },
58
+ ): Promise<FsSuggestResult> {
59
+ const headers: Record<string, string> = { 'content-type': 'application/json' };
60
+ if (opts.token !== undefined && opts.token !== '') {
61
+ headers['authorization'] = `Bearer ${opts.token}`;
62
+ }
63
+ const doFetch = opts.fetchImpl ?? fetch;
64
+ const res = await doFetch(`${opts.baseUrl.replace(/\/$/, '')}/api/v1/fs:suggest`, {
65
+ method: 'POST',
66
+ headers,
67
+ body: JSON.stringify(body),
68
+ });
69
+ const envelope = (await res.json()) as { code: number; msg: string; data: unknown };
70
+ if (envelope.code !== 0) {
71
+ throw new Error(`fs:suggest failed (${envelope.code}): ${envelope.msg}`);
72
+ }
73
+ const data = envelope.data as Record<string, unknown> | null;
74
+ if (data === null || typeof data !== 'object' || !Array.isArray(data['items'])) {
75
+ throw new Error('fs:suggest: unexpected response shape');
76
+ }
77
+ return {
78
+ items: (data['items'] as unknown[])
79
+ .map(parseItem)
80
+ .filter((item): item is FsSuggestItem => item !== undefined),
81
+ truncated: data['truncated'] === true,
82
+ };
83
+ }
84
+
85
+ export async function fetchFsSuggest(opts: FetchFsSuggestOptions): Promise<FsSuggestResult> {
86
+ return postSuggest({
87
+ roots: [...opts.roots],
88
+ query: opts.query,
89
+ limit: opts.limit,
90
+ follow_gitignore: opts.followGitignore,
91
+ show_hidden: opts.showHidden,
92
+ include_globs: opts.includeGlobs,
93
+ exclude_globs: opts.excludeGlobs,
94
+ runtime_id: opts.runtimeId,
95
+ }, opts);
96
+ }
apps/kimi-inspect/src/index.css ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @import 'tailwindcss';
2
+
3
+ :root {
4
+ color-scheme: dark;
5
+ }
6
+
7
+ body {
8
+ margin: 0;
9
+ background: #0b0d10;
10
+ color: #d6dae0;
11
+ font-family:
12
+ ui-sans-serif,
13
+ system-ui,
14
+ -apple-system,
15
+ 'Segoe UI',
16
+ Roboto,
17
+ sans-serif;
18
+ font-size: 13px;
19
+ }
20
+
21
+ * {
22
+ scrollbar-width: thin;
23
+ scrollbar-color: #2b313a transparent;
24
+ }
25
+
26
+ ::-webkit-scrollbar {
27
+ width: 8px;
28
+ height: 8px;
29
+ }
30
+ ::-webkit-scrollbar-thumb {
31
+ background: #2b313a;
32
+ border-radius: 4px;
33
+ }