pi-one-ui 0.7.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/CHANGELOG.md +25 -1
  2. package/README.en.md +19 -6
  3. package/README.md +19 -6
  4. package/extensions/app/config/context.ts +42 -0
  5. package/extensions/app/config/editor.ts +80 -0
  6. package/extensions/app/config/footer.ts +291 -0
  7. package/extensions/app/config/panel.ts +1 -5
  8. package/extensions/app/config/renderer.ts +1 -6
  9. package/extensions/app/config/shell.ts +358 -1435
  10. package/extensions/app/config/store.ts +22 -1
  11. package/extensions/app/config/values.ts +33 -0
  12. package/extensions/app/config/working-line.ts +100 -0
  13. package/extensions/app/overlay/input-router.ts +56 -32
  14. package/extensions/app/overlay/overlay-manager.ts +183 -21
  15. package/extensions/app/runtime/event-coordinator.ts +90 -42
  16. package/extensions/{features → app/runtime}/flush-docked-bash.ts +12 -2
  17. package/extensions/app/runtime/render-scheduler.ts +35 -31
  18. package/extensions/app/runtime/tui-runtime.ts +315 -312
  19. package/extensions/app/settings/controller.ts +202 -0
  20. package/extensions/app/settings/panel.ts +81 -0
  21. package/extensions/app/settings/presets.ts +43 -0
  22. package/extensions/features/context-inspector/index.ts +83 -662
  23. package/extensions/layouts/context/index.ts +58 -161
  24. package/extensions/layouts/context/message/user-message.ts +1 -1
  25. package/extensions/layouts/context/renderer/index.ts +31 -13
  26. package/extensions/layouts/context/renderer/mouse/interaction.ts +29 -14
  27. package/extensions/layouts/editor/controller.ts +9 -18
  28. package/extensions/layouts/editor/factory.ts +3 -3
  29. package/extensions/layouts/footer/controller.ts +30 -19
  30. package/extensions/layouts/footer/footer.ts +2 -2
  31. package/extensions/layouts/overlay/context-inspector.ts +497 -0
  32. package/extensions/{app → layouts}/overlay/selector-border.ts +5 -5
  33. package/extensions/{app → layouts}/overlay/selector-controller.ts +1 -1
  34. package/extensions/layouts/overlay/settings-panel.ts +359 -0
  35. package/extensions/{app/commands → layouts/overlay}/settings-previews.ts +4 -4
  36. package/extensions/layouts/working-line/controller.ts +3 -29
  37. package/extensions/layouts/working-line/working-line.ts +17 -114
  38. package/extensions/shared/working-line-text.ts +108 -0
  39. package/package.json +1 -1
  40. package/extensions/app/host/pi-extension-port.ts +0 -25
  41. package/extensions/app/host/pi-ui-port.ts +0 -52
  42. package/extensions/app/host/tui-capabilities.ts +0 -27
  43. package/extensions/app/ownership/layout-registry.ts +0 -65
  44. package/extensions/app/ownership.ts +0 -32
  45. package/extensions/app/panel.ts +0 -770
  46. package/extensions/app/presets.ts +0 -35
  47. package/extensions/app/runtime/runtime-state.ts +0 -83
  48. /package/extensions/{layouts/working-line → app/config}/working-line-messages.ts +0 -0
  49. /package/extensions/app/{ownership → runtime}/prototype-patch-registry.ts +0 -0
@@ -1,20 +1,24 @@
1
+ import { isDeepStrictEqual } from "node:util";
1
2
  import type {
2
3
  ExtensionAPI,
3
4
  ExtensionContext,
4
5
  Theme,
5
6
  } from "@earendil-works/pi-coding-agent";
7
+ import aliases from "../../features/aliases.ts";
8
+ import contextInspector from "../../features/context-inspector/index.ts";
9
+ import effortCommand from "../../features/effort-command.ts";
10
+ import sessionReference from "../../features/session-reference/index.ts";
11
+ import subagentAutocomplete from "../../features/subagent-autocomplete.ts";
6
12
  import registerContext, {
7
- type ContextExtensionOptions,
8
13
  type ContextRuntimeController,
9
14
  } from "../../layouts/context/index.ts";
10
- import { EditorLayoutController } from "../../layouts/editor/controller.ts";
11
- import { FooterLayoutController } from "../../layouts/footer/controller.ts";
12
- import registerHeaderLayout from "../../layouts/header/index.ts";
13
- import {
14
- WorkingLineLayoutController,
15
- type WorkingLineMessage,
16
- type WorkingLineMessageEnd,
17
- } from "../../layouts/working-line/controller.ts";
15
+ import { EditorLayoutController } from "../../layouts/editor/index.ts";
16
+ import { FooterLayoutController } from "../../layouts/footer/index.ts";
17
+ import registerHeader, {
18
+ applyStartupHeader,
19
+ } from "../../layouts/header/index.ts";
20
+ import { SelectorController } from "../../layouts/overlay/selector-controller.ts";
21
+ import { WorkingLineLayoutController } from "../../layouts/working-line/controller.ts";
18
22
  import {
19
23
  renderTurnSummaryEntry,
20
24
  TURN_SUMMARY_ENTRY_TYPE,
@@ -29,261 +33,202 @@ import {
29
33
  syncState,
30
34
  } from "../../services/session-state.ts";
31
35
  import { resolveFooterTelemetry } from "../../services/telemetry.ts";
36
+ import { type Config, config, setConfig } from "../config/renderer.ts";
32
37
  import {
33
- ensureConfigExists,
38
+ type EditorComponentConfig,
34
39
  hasUnsupportedComponentStyle,
35
40
  loadConfig,
36
- mergeConfig,
37
41
  type PolishedTuiConfig,
38
- saveEditorComponentPatch,
39
- saveFooterComponentPatch,
40
- saveWorkingLineComponentPatch,
41
42
  } from "../config/shell.ts";
42
- import { type ConfigRecord, configStore } from "../config/store.ts";
43
+ import { InputRouter } from "../overlay/input-router.ts";
44
+ import { OverlayManager } from "../overlay/overlay-manager.ts";
43
45
  import {
44
- createPiExtensionPort,
45
- type PiExtensionPort,
46
- } from "../host/pi-extension-port.ts";
47
- import { createPiUiPort, type PiUiPort } from "../host/pi-ui-port.ts";
48
- import { SelectorController } from "../overlay/selector-controller.ts";
49
- import { LayoutRegistry } from "../ownership/layout-registry.ts";
50
- import { showOneUiPanel, type TuiPanelRuntime } from "../panel.ts";
46
+ type SettingsChange,
47
+ SettingsController,
48
+ } from "../settings/controller.ts";
49
+ import { showOneUiPanel } from "../settings/panel.ts";
51
50
  import { EventCoordinator } from "./event-coordinator.ts";
51
+ import { installFlushDockedBash } from "./flush-docked-bash.ts";
52
52
  import { RenderScheduler } from "./render-scheduler.ts";
53
- import { RuntimeStateStore } from "./runtime-state.ts";
54
53
  import { SessionLifecycle } from "./session-lifecycle.ts";
55
54
 
56
- export type TuiRuntimeContext = {
57
- readonly extensions: PiExtensionPort;
58
- readonly state: RuntimeStateStore;
59
- readonly render: RenderScheduler;
60
- readonly layouts: LayoutRegistry;
61
- readonly ui: () => PiUiPort | undefined;
62
- };
63
-
64
- /**
65
- * The single composition root for the plugin. Remaining compatibility glue
66
- * is mounted behind this seam while Layout ownership is finalized.
67
- */
55
+ /** 产品入口只组织组件、Feature 和共享生命周期。 */
68
56
  export class TuiRuntime {
69
- private readonly pi: ExtensionAPI;
70
- readonly extensions: PiExtensionPort;
71
- readonly state = new RuntimeStateStore();
72
- readonly layouts = new LayoutRegistry();
73
-
57
+ readonly render = new RenderScheduler();
58
+ readonly settings = new SettingsController();
59
+ readonly overlays = new OverlayManager();
60
+ readonly input = new InputRouter();
74
61
  private readonly coordinator: EventCoordinator;
75
- private readonly renderScheduler: RenderScheduler;
76
- private activeUi: PiUiPort | undefined;
62
+ private readonly coordinatedPi: ExtensionAPI;
63
+ private context: ContextRuntimeController | undefined;
77
64
  private installed = false;
78
- private editorController: EditorLayoutController | undefined;
79
- private footerController: FooterLayoutController | undefined;
80
- private workingLineController: WorkingLineLayoutController | undefined;
81
- private contextController: ContextRuntimeController | undefined;
65
+ private currentContext: ExtensionContext | undefined;
82
66
 
83
- /**
84
- * Creates the runtime services and coordinates session lifecycle ownership.
85
- */
86
67
  constructor(pi: ExtensionAPI) {
87
- this.pi = pi;
88
- this.extensions = createPiExtensionPort(pi);
89
- this.renderScheduler = new RenderScheduler((force) =>
90
- this.activeUi?.requestRender(force),
91
- );
92
68
  this.coordinator = new EventCoordinator({
93
- on: (event, handler) =>
94
- this.extensions.on(event as never, handler as never),
69
+ on: (event, handler) => pi.on(event as never, handler as never),
95
70
  });
71
+ this.coordinatedPi = this.coordinator.coordinate(pi);
96
72
  }
97
73
 
98
- /**
99
- * Returns the shared render scheduler used by installed layouts.
100
- */
101
- get render(): RenderScheduler {
102
- return this.renderScheduler;
103
- }
104
-
105
- /**
106
- * Returns the active session UI port, when a TUI session is mounted.
107
- */
108
- ui(): PiUiPort | undefined {
109
- return this.activeUi;
110
- }
111
-
112
- /**
113
- * Exposes the narrow runtime contract consumed by the settings panel.
114
- *
115
- * @returns Layout-independent panel operations.
116
- */
117
- private panelRuntime(): TuiPanelRuntime {
118
- return {
119
- setEditorComponent: (patch, ctx) =>
120
- this.editorController?.setComponent(patch, ctx) ?? { applied: false },
121
- setUserMessagesComponent: (patch, ctx) =>
122
- this.contextController?.setUserMessagesComponent(patch, ctx),
123
- setWorkingLineComponent: (patch, ctx) =>
124
- this.workingLineController?.setComponent(patch, ctx) ?? {
125
- applied: false,
126
- reason: "WorkingLine runtime is not available",
127
- },
128
- setFooterComponent: (patch, ctx) =>
129
- this.footerController?.setComponent(patch, ctx),
130
- setContextMode: (mode, ctx) => this.contextController?.setMode(mode, ctx),
131
- updateContextConfig: (patch, ctx) =>
132
- this.contextController?.updateConfig(patch, ctx),
133
- };
134
- }
135
-
136
- /**
137
- * Installs the runtime once and mounts remaining layout lifecycle glue.
138
- */
139
74
  install(): void {
140
- if (this.installed) return;
75
+ if (this.installed) {
76
+ return;
77
+ }
141
78
  this.installed = true;
142
-
143
- const contextOptions: ContextExtensionOptions = {
144
- onRuntimeController: (controller) => {
145
- this.contextController = controller;
79
+ setConfig(this.settings.snapshot().renderer);
80
+ const pi = this.coordinatedPi;
81
+ this.coordinator.on("session_start", (_event, ctx) => {
82
+ this.overlays.startSession();
83
+ this.input.clear();
84
+ this.currentContext = ctx;
85
+ });
86
+ this.coordinator.on("session_shutdown", () => {
87
+ this.overlays.reset();
88
+ this.input.clear();
89
+ });
90
+ const bindings = createLayoutRuntime(pi, {
91
+ render: this.render,
92
+ settings: this.settings,
93
+ onConfigChanged: (previous, ctx) => {
94
+ this.context?.applyConfig(ctx, previous);
95
+ if (previous.showStartupHeader !== config.showStartupHeader) {
96
+ applyStartupHeader(ctx);
97
+ }
146
98
  },
147
- };
148
-
149
- const layoutBindings = createLayoutRuntime(this.pi, this.coordinator);
150
- layoutBindings.workingLineController.setSummaryWriterEnabled(false);
151
- this.editorController = layoutBindings.editorController;
152
- this.footerController = layoutBindings.footerController;
153
- this.workingLineController = layoutBindings.workingLineController;
154
- layoutBindings.installEventHandlers(this.coordinator);
155
- registerHeaderLayout(this.pi);
156
- registerContext(this.pi, contextOptions);
157
- this.coordinator.on("session_start", async (_event, ctx) => {
158
- this.state.start(ctx.mode);
99
+ });
100
+ bindings.workingLineController.setSummaryWriterEnabled(false);
101
+ bindings.installEventHandlers(this.coordinator);
102
+ registerHeader(pi);
103
+ this.context = registerContext(pi, {
104
+ getConfig: bindings.getConfig,
105
+ services: {
106
+ input: this.input,
107
+ overlays: this.overlays,
108
+ render: this.render,
109
+ },
110
+ });
111
+ if (config.enableAliases) {
112
+ aliases(pi);
113
+ }
114
+ if (config.enableEffortCommand) {
115
+ effortCommand(pi);
116
+ }
117
+ if (config.enableContextCommand) {
118
+ contextInspector(pi, this.overlays);
119
+ }
120
+ if (config.enableSessionReference) {
121
+ sessionReference(pi);
122
+ }
123
+ if (config.enableSubagentAutocomplete) {
124
+ subagentAutocomplete(pi);
125
+ }
126
+ let cleanupBash: (() => void) | undefined;
127
+ this.coordinator.on("session_start", (_event, ctx) => {
159
128
  if (ctx.mode === "tui" && ctx.hasUI) {
160
- // Pi exposes redraw through concrete TUI components; layouts will
161
- // connect that callback when they migrate behind this runtime.
162
- this.activeUi = createPiUiPort(ctx);
129
+ cleanupBash = installFlushDockedBash();
163
130
  }
164
131
  });
165
- this.coordinator.on("session_shutdown", (_event, ctx) => {
166
- this.activeUi = undefined;
167
- this.state.shutdown();
132
+ this.coordinator.on("session_shutdown", () => {
133
+ cleanupBash?.();
134
+ cleanupBash = undefined;
135
+ this.currentContext = undefined;
168
136
  });
169
137
  this.coordinator.install();
170
-
171
- this.extensions.registerCommand("oneui", {
138
+ pi.registerCommand("oneui", {
172
139
  description: "Open pi-one-ui settings",
173
140
  handler: async (_args, ctx) => {
174
- await showOneUiPanel(ctx, { runtime: this.panelRuntime() });
141
+ await showOneUiPanel(ctx, {
142
+ settings: this.settings,
143
+ overlays: this.overlays,
144
+ });
175
145
  },
176
146
  });
177
147
  }
148
+
149
+ async dispose(ctx = this.currentContext): Promise<void> {
150
+ if (ctx && this.currentContext) {
151
+ await this.coordinator.dispatch(
152
+ "session_shutdown",
153
+ { type: "session_shutdown", reason: "quit" },
154
+ ctx,
155
+ );
156
+ }
157
+ }
178
158
  }
179
159
 
180
- /**
181
- * Creates a single TUI runtime for the plugin composition root.
182
- */
183
160
  export function createTuiRuntime(pi: ExtensionAPI): TuiRuntime {
184
161
  return new TuiRuntime(pi);
185
162
  }
186
163
 
187
- /**
188
- * Reports whether a context exposes the interactive TUI layout.
189
- *
190
- * @param ctx Candidate Pi extension context.
191
- * @returns Whether the context can install TUI-owned layouts.
192
- */
193
164
  function isTuiContext(ctx: ExtensionContext): boolean {
194
- try {
195
- const mode = ctx.mode;
196
- return ctx.hasUI && (mode === undefined || mode === "tui");
197
- } catch {
198
- return false;
199
- }
165
+ const mode = ctx.mode;
166
+ return ctx.hasUI && (mode === undefined || mode === "tui");
200
167
  }
201
168
 
202
- /**
203
- * Creates the layout controllers and shared services used by TuiRuntime.
204
- *
205
- * @param pi Pi extension API.
206
- * @param eventCoordinator Shared lifecycle event coordinator.
207
- * @returns Layout controllers and lifecycle registration bindings.
208
- */
209
- function createLayoutRuntime(
169
+ type LayoutRuntimeOptions = {
170
+ render?: RenderScheduler;
171
+ settings?: SettingsController;
172
+ onConfigChanged?: (previous: Config, ctx: ExtensionContext) => void;
173
+ };
174
+
175
+ /** 生产运行时与组件集成测试共同使用的实际装配。 */
176
+ export function createLayoutRuntime(
210
177
  pi: ExtensionAPI,
211
- eventCoordinator: EventCoordinator,
178
+ options: LayoutRuntimeOptions = {},
212
179
  ) {
213
180
  const sessionLifecycle = new SessionLifecycle();
181
+ const render = options.render ?? new RenderScheduler();
182
+ const settings = options.settings ?? new SettingsController();
214
183
  let currentConfig: PolishedTuiConfig = loadConfig();
215
- configStore.subscribe((record: ConfigRecord) => {
216
- currentConfig = mergeConfig(record);
217
- });
184
+ let unsubscribeConfig: (() => void) | undefined;
185
+ let activeTheme: Theme | undefined;
186
+ let minimalistProjectRoot: string | undefined;
187
+ let activeTuiContext: ExtensionContext | undefined;
218
188
  const sessionState = new SessionStateService({
219
189
  getCacheHitIcon: () => currentConfig.icons.cacheHit,
220
190
  resolveTelemetry: resolveFooterTelemetry,
221
191
  syncState,
222
192
  });
223
193
  const state = sessionState.state;
224
- // Keep the capability guard defensive for hosts with incomplete extension APIs.
225
194
  if (typeof pi.registerEntryRenderer === "function") {
226
- pi.registerEntryRenderer(TURN_SUMMARY_ENTRY_TYPE, (entry, options, theme) =>
227
- renderTurnSummaryEntry(
228
- entry,
229
- {
230
- ...options,
231
- workingLineHigh: currentConfig.colors.workingLineHigh,
232
- },
233
- theme,
234
- ),
195
+ pi.registerEntryRenderer(
196
+ TURN_SUMMARY_ENTRY_TYPE,
197
+ (entry, entryOptions, theme) =>
198
+ renderTurnSummaryEntry(
199
+ entry,
200
+ {
201
+ ...entryOptions,
202
+ workingLineHigh: currentConfig.colors.workingLineHigh,
203
+ },
204
+ theme,
205
+ ),
235
206
  );
236
207
  }
237
- let activeTheme: Theme | undefined;
238
- let minimalistProjectRoot: string | undefined;
239
- let activeTuiContext: ExtensionContext | undefined;
240
-
241
- /**
242
- * Requests redraws from the Editor controller and shared render seam.
243
- */
244
- const refresh = () => {
245
- if (!sessionLifecycle.isCurrent()) return;
246
- editorController.requestRender();
208
+ const refresh = (): void => {
209
+ if (sessionLifecycle.isCurrent()) {
210
+ render.request();
211
+ }
247
212
  };
248
213
  const liveContext = new LiveContextController(sessionLifecycle, refresh);
249
- const getActiveTheme = () => activeTheme;
250
- const getCurrentConfig = () => currentConfig;
251
-
252
- /**
253
- * Reloads the canonical configuration after an external test-support write.
254
- *
255
- * @returns The refreshed normalized configuration snapshot.
256
- */
257
- const reloadConfig = (): PolishedTuiConfig => {
258
- currentConfig = loadConfig();
259
- return currentConfig;
260
- };
214
+ const getConfig = () => currentConfig;
215
+ const syncFooterState = (ctx: ExtensionContext) => sessionState.sync(ctx);
261
216
  const workingLineController = new WorkingLineLayoutController({
262
217
  pi,
263
- getConfig: getCurrentConfig,
264
- saveComponent: (patch) => {
265
- currentConfig = saveWorkingLineComponentPatch(patch);
266
- return currentConfig;
267
- },
218
+ getConfig,
268
219
  getTheme: () => activeTheme as Theme,
269
220
  sessionLifecycle,
270
221
  refresh,
271
- onAgentActiveChanged: (active) => {
272
- editorController.setAgentRunActive(active);
273
- },
222
+ onAgentActiveChanged: (active) =>
223
+ editorController.setAgentRunActive(active),
274
224
  });
275
- const getThinkingLevel = () =>
276
- sessionLifecycle.isCurrent() ? pi.getThinkingLevel() : ("off" as const);
277
225
  const editorController = new EditorLayoutController({
278
- getConfig: getCurrentConfig,
279
- saveComponent: (patch) => {
280
- currentConfig = saveEditorComponentPatch(patch);
281
- return currentConfig;
282
- },
226
+ getConfig,
283
227
  getState: () => state,
284
228
  sessionLifecycle,
285
- render: { request: () => footerController.requestRender() },
286
- getThinkingLevel,
229
+ render,
230
+ getThinkingLevel: () =>
231
+ sessionLifecycle.isCurrent() ? pi.getThinkingLevel() : "off",
287
232
  getAgentDurationMs: () => workingLineController.duration().elapsedMs() ?? 0,
288
233
  isAgentActive: () => workingLineController.isAgentActive(),
289
234
  isAgentDurationActive: () => workingLineController.duration().isActive(),
@@ -291,13 +236,14 @@ function createLayoutRuntime(
291
236
  workingLineController.duration().subscribe(() => listener()),
292
237
  getProjectRoot: () => minimalistProjectRoot,
293
238
  onProjectRequirementChanged: () => {
294
- if (activeTuiContext) projectRefreshService.reconcile(activeTuiContext);
239
+ if (activeTuiContext) {
240
+ projectRefreshService.reconcile(activeTuiContext);
241
+ }
295
242
  },
296
- onModelLabelChanged: (ctx) => syncFooterState(ctx),
243
+ onModelLabelChanged: syncFooterState,
297
244
  });
298
- const syncFooterState = (ctx: ExtensionContext) => sessionState.sync(ctx);
299
245
  const projectRefreshService = new ProjectRefreshService({
300
- getConfig: getCurrentConfig,
246
+ getConfig,
301
247
  state,
302
248
  sessionLifecycle,
303
249
  getFooterReferences: () => footerController.installedFooterReferences(),
@@ -312,133 +258,206 @@ function createLayoutRuntime(
312
258
  });
313
259
  const scheduleProjectRefresh = (
314
260
  ctx: ExtensionContext,
315
- options?: ScheduleProjectRefreshOptions,
316
- ) => projectRefreshService.schedule(ctx, options);
317
-
318
- /**
319
- * Synchronizes shared session state and requests a visible redraw.
320
- *
321
- * @param ctx Active Pi extension context.
322
- * @param project Whether project data should be scheduled.
323
- */
324
- const refreshInteractiveState = (ctx: ExtensionContext, project = false) => {
325
- if (!sessionLifecycle.isCurrent() || !ctx.hasUI) return;
326
- editorController.reconcileOwnership(ctx);
327
- syncFooterState(ctx);
328
- if (project) scheduleProjectRefresh(ctx);
329
- refresh();
330
- };
331
-
332
- const selectorController = new SelectorController({
333
- getConfig: getCurrentConfig,
334
- });
261
+ refreshOptions?: ScheduleProjectRefreshOptions,
262
+ ) => projectRefreshService.schedule(ctx, refreshOptions);
263
+ const selectorController = new SelectorController({ getConfig });
335
264
  const footerController = new FooterLayoutController({
336
- getConfig: getCurrentConfig,
337
- saveComponent: (patch) => {
338
- currentConfig = saveFooterComponentPatch(patch);
339
- return currentConfig;
340
- },
265
+ getConfig,
341
266
  state,
342
267
  sessionLifecycle,
268
+ render,
343
269
  refresh,
344
270
  scheduleProjectRefresh: (ctx) => scheduleProjectRefresh(ctx),
345
271
  getLiveContext: () => liveContext.get(),
346
272
  onProjectRequirementChanged: (ctx, force) =>
347
273
  projectRefreshService.reconcile(ctx, force),
348
- onModelLabelChanged: (ctx) => syncFooterState(ctx),
274
+ onModelLabelChanged: syncFooterState,
349
275
  });
350
276
 
351
- const applyConfiguredUi = (ctx: ExtensionContext) => {
352
- if (!isTuiContext(ctx)) return;
277
+ const applyConfig = (
278
+ previous: PolishedTuiConfig,
279
+ previousRenderer: Config,
280
+ ctx: ExtensionContext,
281
+ change?: SettingsChange,
282
+ ): void => {
283
+ if (!sessionLifecycle.isCurrent() || !isTuiContext(ctx)) {
284
+ return;
285
+ }
286
+ const oldEditor = previous.components.editor;
287
+ const editor = currentConfig.components.editor;
288
+ const patch: Partial<EditorComponentConfig> = {};
289
+ for (const key of [
290
+ "style",
291
+ "modelLabel",
292
+ "borderColorMode",
293
+ "viewportIndicators",
294
+ ] as const) {
295
+ if (oldEditor[key] !== editor[key]) {
296
+ Object.assign(patch, { [key]: editor[key] });
297
+ }
298
+ }
299
+ const preset = change?.id.startsWith("preset:") ?? false;
300
+ if (
301
+ preset ||
302
+ change?.id === "editorStyle" ||
303
+ hasUnsupportedComponentStyle(previous, "editor") !==
304
+ hasUnsupportedComponentStyle(currentConfig, "editor")
305
+ ) {
306
+ patch.style = editor.style;
307
+ }
308
+ const editorChange = editorController.applyConfig(patch, ctx);
309
+ const oldFooter = previous.components.footer;
310
+ const footer = currentConfig.components.footer;
311
+ const footerChange = footerController.applyConfig(
312
+ {
313
+ ...(oldFooter.style !== footer.style ||
314
+ preset ||
315
+ change?.id === "footerStyle" ||
316
+ hasUnsupportedComponentStyle(previous, "footer") !==
317
+ hasUnsupportedComponentStyle(currentConfig, "footer")
318
+ ? { style: footer.style }
319
+ : {}),
320
+ ...(oldFooter.modelLabel !== footer.modelLabel
321
+ ? { modelLabel: footer.modelLabel }
322
+ : {}),
323
+ },
324
+ ctx,
325
+ oldFooter.style,
326
+ );
327
+ const workingLineUpdated = Boolean(
328
+ preset ||
329
+ !isDeepStrictEqual(
330
+ previous.components.workingLine,
331
+ currentConfig.components.workingLine,
332
+ ),
333
+ );
334
+ const workingLineChange = workingLineUpdated
335
+ ? workingLineController.applyConfig(ctx)
336
+ : undefined;
353
337
  selectorController.reconcile();
354
- projectRefreshService.reconcile(ctx);
355
- footerController.reconcileSessionTimer();
338
+ options.onConfigChanged?.(previousRenderer, ctx);
339
+ refresh();
340
+ const failures = [
341
+ editorChange,
342
+ footerChange,
343
+ ...(workingLineChange ? [workingLineChange] : []),
344
+ ]
345
+ .filter((result) => !result.applied)
346
+ .map(
347
+ (result) =>
348
+ new Error(
349
+ result.reason ?? "Layout could not apply the configuration",
350
+ ),
351
+ );
352
+ if (failures.length > 0) {
353
+ throw new AggregateError(
354
+ failures,
355
+ "Layout configuration could not be applied",
356
+ );
357
+ }
358
+ };
359
+ const subscribeConfig = (): void => {
360
+ unsubscribeConfig?.();
361
+ unsubscribeConfig = settings.subscribe((snapshot, change) => {
362
+ const previous = currentConfig;
363
+ const previousRenderer = { ...config };
364
+ currentConfig = snapshot.shell;
365
+ setConfig(snapshot.renderer);
366
+ if (activeTuiContext) {
367
+ applyConfig(previous, previousRenderer, activeTuiContext, change);
368
+ }
369
+ });
356
370
  };
357
371
 
358
- /**
359
- * Installs compatibility layouts around the extracted Editor controller.
360
- */
361
- const installUi = (ctx: ExtensionContext) => {
362
- if (!isTuiContext(ctx)) return;
372
+ const refreshInteractiveState = (
373
+ ctx: ExtensionContext,
374
+ project = false,
375
+ ): void => {
376
+ if (!sessionLifecycle.isCurrent() || !ctx.hasUI) {
377
+ return;
378
+ }
379
+ editorController.reconcileOwnership(ctx);
380
+ syncFooterState(ctx);
381
+ if (project) {
382
+ scheduleProjectRefresh(ctx);
383
+ }
384
+ refresh();
385
+ };
386
+ const reloadConfig = (): PolishedTuiConfig => {
387
+ currentConfig = loadConfig();
388
+ return currentConfig;
389
+ };
390
+ const installUi = (ctx: ExtensionContext): void => {
391
+ if (!isTuiContext(ctx)) {
392
+ return;
393
+ }
363
394
  activeTuiContext = ctx;
364
395
  activeTheme = ctx.ui.theme;
365
- ensureConfigExists();
366
- currentConfig = loadConfig();
396
+ reloadConfig();
367
397
  syncFooterState(ctx);
368
398
  projectRefreshService.stop();
369
-
370
399
  footerController.install(ctx);
371
400
  editorController.install(ctx, true);
372
401
  selectorController.startSession(ctx);
373
- applyConfiguredUi(ctx);
402
+ projectRefreshService.reconcile(ctx);
403
+ footerController.reconcileSessionTimer();
374
404
  refresh();
375
405
  };
376
-
377
- /**
378
- * Cleans all session-owned layout resources in dependency order.
379
- */
380
- const cleanupUi = (ctx?: ExtensionContext) => {
381
- if (!ctx || !sessionLifecycle.isCurrent()) return;
406
+ const dispose = (ctx = activeTuiContext): void => {
407
+ if (!sessionLifecycle.isCurrent()) {
408
+ return;
409
+ }
410
+ sessionLifecycle.shutdown();
411
+ activeTuiContext = undefined;
412
+ unsubscribeConfig?.();
413
+ unsubscribeConfig = undefined;
414
+ liveContext.shutdown();
415
+ if (ctx) {
416
+ workingLineController.dispose(ctx);
417
+ }
382
418
  editorController.cleanup(ctx);
383
419
  footerController.cleanup(ctx);
384
420
  projectRefreshService.stop();
385
421
  selectorController.cleanup();
386
422
  activeTheme = undefined;
387
- activeTuiContext = undefined;
423
+ render.dispose();
388
424
  };
389
-
390
- const syncInteractiveState = (_event: unknown, ctx: ExtensionContext) => {
425
+ const syncInteractiveState = (_event: unknown, ctx: ExtensionContext) =>
391
426
  refreshInteractiveState(ctx);
392
- };
393
427
  const syncInteractiveAndProjectState = (
394
428
  _event: unknown,
395
429
  ctx: ExtensionContext,
396
- ) => {
430
+ ) => refreshInteractiveState(ctx, true);
431
+ const syncWithUsage = (_event: unknown, ctx: ExtensionContext) => {
432
+ sessionState.invalidateUsageCache();
397
433
  refreshInteractiveState(ctx, true);
398
434
  };
399
435
 
400
- /**
401
- * Registers session and runtime effects with the supplied event coordinator.
402
- *
403
- * @param coordinator Shared lifecycle event coordinator.
404
- */
405
436
  const installEventHandlers = (coordinator: EventCoordinator): void => {
406
437
  coordinator.on("session_start", (_event, ctx) => {
438
+ dispose();
407
439
  sessionLifecycle.start();
440
+ render.reset();
441
+ subscribeConfig();
408
442
  liveContext.startSession();
409
443
  sessionState.startSession();
410
444
  minimalistProjectRoot = undefined;
411
445
  installUi(ctx);
412
446
  workingLineController.startSession(ctx);
413
447
  });
414
-
415
- coordinator.on("session_shutdown", async (_event, ctx) => {
416
- liveContext.shutdown();
417
- workingLineController.dispose(ctx);
418
- cleanupUi(ctx);
419
- });
420
-
421
- const syncInteractiveAndProjectStateWithUsage = (
422
- _event: unknown,
423
- ctx: ExtensionContext,
424
- ) => {
425
- sessionState.invalidateUsageCache();
426
- refreshInteractiveState(ctx, true);
427
- };
428
-
448
+ coordinator.on("session_shutdown", (_event, ctx) => dispose(ctx));
429
449
  coordinator.on("agent_start", (event, ctx) => {
430
450
  liveContext.clear();
431
451
  workingLineController.agentStart(ctx);
432
452
  syncInteractiveState(event, ctx);
433
453
  });
434
- coordinator.on("turn_start", (_event, ctx) => {
435
- workingLineController.turnStart(ctx);
436
- });
454
+ coordinator.on("turn_start", (_event, ctx) =>
455
+ workingLineController.turnStart(ctx),
456
+ );
437
457
  coordinator.on("agent_end", (event, ctx) => {
438
458
  liveContext.clear();
439
459
  workingLineController.agentEnd(ctx);
440
- // Reconcile once more after Pi has persisted the assistant message.
441
- syncInteractiveAndProjectStateWithUsage(event, ctx);
460
+ syncWithUsage(event, ctx);
442
461
  });
443
462
  coordinator.on("model_select", (event, ctx) => {
444
463
  liveContext.clear();
@@ -447,68 +466,51 @@ function createLayoutRuntime(
447
466
  coordinator.on("thinking_level_select", syncInteractiveState);
448
467
  coordinator.on("session_info_changed", syncInteractiveState);
449
468
  coordinator.on("message_update", (event, ctx) => {
450
- const payload = event as {
451
- message: WorkingLineMessage;
452
- assistantMessageEvent?: Parameters<
453
- WorkingLineLayoutController["messageUpdate"]
454
- >[1];
455
- };
456
- liveContext.update(payload.message);
469
+ liveContext.update(event.message);
457
470
  workingLineController.messageUpdate(
458
- payload.message,
459
- payload.assistantMessageEvent,
471
+ event.message,
472
+ event.assistantMessageEvent,
460
473
  ctx,
461
474
  );
462
475
  });
463
476
  coordinator.on("message_end", (event, ctx) => {
464
- const payload = event as {
465
- message: WorkingLineMessageEnd & { stopReason?: string };
466
- };
467
- const result = workingLineController.messageEnd(payload.message, ctx);
468
- // Pi notifies extensions before persisting a successful message, so retain its live
469
- // context until agent_end; accepted failed messages clear immediately instead of showing
470
- // stale usage. Rejected and duplicate finals are not authoritative.
477
+ const result = workingLineController.messageEnd(event.message, ctx);
471
478
  if (
472
479
  result.status === "accepted" &&
473
- payload.message.role === "assistant" &&
474
- (payload.message.stopReason === "error" ||
475
- payload.message.stopReason === "aborted")
480
+ event.message.role === "assistant" &&
481
+ (event.message.stopReason === "error" ||
482
+ event.message.stopReason === "aborted")
476
483
  ) {
477
484
  liveContext.clear();
478
485
  }
479
- syncInteractiveAndProjectStateWithUsage(event, ctx);
480
- });
481
- coordinator.on("agent_settled", (_event, ctx) => {
482
- workingLineController.agentSettled(ctx);
486
+ syncWithUsage(event, ctx);
483
487
  });
488
+ coordinator.on("agent_settled", (_event, ctx) =>
489
+ workingLineController.agentSettled(ctx),
490
+ );
484
491
  coordinator.on("tool_execution_start", (event, ctx) => {
485
492
  liveContext.clear();
486
- const payload = event as { toolCallId: string; toolName: string };
487
- workingLineController.toolStart(
488
- payload.toolCallId,
489
- payload.toolName,
490
- ctx,
491
- );
493
+ workingLineController.toolStart(event.toolCallId, event.toolName, ctx);
492
494
  syncInteractiveState(event, ctx);
493
495
  });
494
496
  coordinator.on("tool_execution_end", (event, ctx) => {
495
- const payload = event as { toolCallId: string };
496
- workingLineController.toolEnd(payload.toolCallId, ctx);
497
+ workingLineController.toolEnd(event.toolCallId, ctx);
497
498
  syncInteractiveAndProjectState(event, ctx);
498
499
  });
499
500
  coordinator.on("session_compact", (event, ctx) => {
500
501
  liveContext.clear();
501
- syncInteractiveAndProjectStateWithUsage(event, ctx);
502
+ syncWithUsage(event, ctx);
502
503
  });
503
504
  coordinator.on("session_tree", (event, ctx) => {
504
505
  liveContext.clear();
505
- syncInteractiveAndProjectStateWithUsage(event, ctx);
506
+ syncWithUsage(event, ctx);
506
507
  });
507
508
  };
508
509
 
509
510
  return {
510
511
  sessionLifecycle,
511
- getConfig: getCurrentConfig,
512
+ settings,
513
+ getConfig,
512
514
  editorController,
513
515
  footerController,
514
516
  workingLineController,
@@ -516,5 +518,6 @@ function createLayoutRuntime(
516
518
  projectRefreshService,
517
519
  reloadConfig,
518
520
  installEventHandlers,
521
+ dispose,
519
522
  };
520
523
  }