@visual-z/xeno 0.4.0 → 0.6.0

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 (90) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +41 -10
  3. package/docs/app-server.md +35 -4
  4. package/docs/foundations.md +88 -0
  5. package/docs/install.md +23 -2
  6. package/docs/providers.md +4 -0
  7. package/docs/research/foundation-clients-2026-10-09.md +27 -0
  8. package/docs/research/foundation-editing-2026-10-09.md +56 -0
  9. package/docs/research/foundation-network-2026-10-09.md +37 -0
  10. package/docs/research/foundation-workflow-2026-10-09.md +37 -0
  11. package/package.json +10 -2
  12. package/src/apps/acp-server.ts +41 -0
  13. package/src/apps/agent-profiles.ts +19 -0
  14. package/src/apps/app-server.ts +4 -2
  15. package/src/apps/cli.ts +18 -2
  16. package/src/apps/commands.ts +20 -0
  17. package/src/apps/control-transport.ts +3 -2
  18. package/src/apps/model-catalog.ts +57 -31
  19. package/src/apps/preferences.ts +20 -0
  20. package/src/apps/question-ui.ts +27 -0
  21. package/src/apps/resources.ts +63 -25
  22. package/src/apps/review.ts +109 -0
  23. package/src/apps/session-hub.ts +84 -0
  24. package/src/apps/shell.ts +88 -30
  25. package/src/apps/startup-input.ts +7 -2
  26. package/src/apps/startup.ts +180 -25
  27. package/src/apps/ui/app.ts +74 -91
  28. package/src/apps/ui/surface.ts +1 -0
  29. package/src/apps/update-check.ts +71 -0
  30. package/src/apps/workflow.ts +58 -0
  31. package/src/core/agent-profile.ts +130 -0
  32. package/src/core/completion-review.ts +13 -4
  33. package/src/core/control-protocol.ts +8 -1
  34. package/src/core/file-precondition.ts +6 -0
  35. package/src/core/lsp-framing.ts +34 -0
  36. package/src/core/mcp-config.ts +68 -0
  37. package/src/core/model-preferences.ts +28 -0
  38. package/src/core/patch.ts +130 -0
  39. package/src/core/permissions.ts +156 -11
  40. package/src/core/question.ts +94 -0
  41. package/src/core/review.ts +68 -0
  42. package/src/core/settings.ts +1 -1
  43. package/src/core/update-check.ts +24 -5
  44. package/src/core/web-content.ts +80 -0
  45. package/src/core/workflow-mode.ts +34 -0
  46. package/src/runtime/acp-agent.ts +289 -0
  47. package/src/runtime/agent-profiles.ts +21 -0
  48. package/src/runtime/catalog.ts +10 -3
  49. package/src/runtime/control-client.ts +149 -0
  50. package/src/runtime/control-connection.ts +39 -4
  51. package/src/runtime/extensions.ts +20 -2
  52. package/src/runtime/formatter-tool.ts +38 -0
  53. package/src/runtime/formatter.ts +50 -0
  54. package/src/runtime/foundation.ts +78 -0
  55. package/src/runtime/integrations.ts +8 -2
  56. package/src/runtime/loop.ts +68 -10
  57. package/src/runtime/lsp-client.ts +201 -0
  58. package/src/runtime/mcp-client.ts +264 -0
  59. package/src/runtime/mcp-oauth.ts +168 -0
  60. package/src/runtime/mcp-schema.ts +51 -0
  61. package/src/runtime/oauth.ts +1 -1
  62. package/src/runtime/output-schema.ts +26 -0
  63. package/src/runtime/patch-tool.ts +38 -0
  64. package/src/runtime/permission-resources.ts +11 -0
  65. package/src/runtime/provider.ts +2 -0
  66. package/src/runtime/question-tool.ts +62 -0
  67. package/src/runtime/review-tool.ts +48 -0
  68. package/src/runtime/session-control.ts +60 -2
  69. package/src/runtime/session-hub.ts +85 -0
  70. package/src/runtime/tool-discovery.ts +4 -3
  71. package/src/runtime/tools.ts +48 -16
  72. package/src/runtime/update-check.ts +3 -2
  73. package/src/runtime/web-http.ts +56 -0
  74. package/src/runtime/web-tools.ts +88 -0
  75. package/src/runtime/workspace-lifecycle.ts +14 -1
  76. package/src/store/agent-profile-files.ts +13 -0
  77. package/src/store/context-files.ts +77 -1
  78. package/src/store/file-checkpoints.ts +5 -4
  79. package/src/store/foundation-config.ts +24 -0
  80. package/src/store/lsp-document.ts +30 -0
  81. package/src/store/mcp-credentials.ts +85 -0
  82. package/src/store/patch.ts +92 -0
  83. package/src/store/permission-paths.ts +34 -0
  84. package/src/store/resources.ts +13 -22
  85. package/src/store/review.ts +109 -0
  86. package/src/store/search.ts +48 -9
  87. package/src/store/user-settings.ts +30 -0
  88. package/src/tui/ui/control-panel.ts +7 -5
  89. package/src/tui/ui/control-sidebar.ts +49 -0
  90. package/src/tui/ui/screen.ts +107 -11
@@ -0,0 +1,30 @@
1
+ /** One read/modify/replace owner prevents automatic selections from racing other settings (Q.25). */
2
+ import { readFile } from "node:fs/promises";
3
+ import { settingsPath } from "./config.ts";
4
+ import { atomicWrite } from "./atomic.ts";
5
+ import { serialized } from "./serial.ts";
6
+
7
+ async function readSettings(path: string): Promise<Record<string, unknown>> {
8
+ let text: string;
9
+ try { text = await readFile(path, "utf8"); }
10
+ catch (error) {
11
+ if (typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT") return {};
12
+ throw error;
13
+ }
14
+ let value: unknown;
15
+ try { value = JSON.parse(text); }
16
+ catch { throw new Error(`${path} is not valid JSON; repair it before saving settings`); }
17
+ if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error(`${path} must contain a settings object`);
18
+ return value as Record<string, unknown>;
19
+ }
20
+
21
+ export function editUserSettings(edit: (previous: Record<string, unknown>) => Record<string, unknown> | null): Promise<void> {
22
+ // Capture the destination before any await, including when another profile changes configDir().
23
+ const path = settingsPath();
24
+ // Packages and resource overrides already coordinate this file through serialized().
25
+ return serialized(path, async () => {
26
+ const next = edit(await readSettings(path));
27
+ if (next === null) return;
28
+ await atomicWrite(path, `${JSON.stringify(next, null, 2)}\n`);
29
+ });
30
+ }
@@ -1,5 +1,5 @@
1
1
  /** View-only data: apps owns the projection and actions, as required by D20. */
2
- import { clipText } from "./edge.ts";
2
+ import { wrapPlain } from "../diff.ts";
3
3
 
4
4
  export type ControlPanelModel = {
5
5
  thread: string; turn: string | null; goal: string; status: string; execution: string; gaps: readonly string[];
@@ -10,14 +10,16 @@ export type ControlTab = "goal" | "progress" | "context";
10
10
  export type ControlFocus = "messages" | "omitted" | "history";
11
11
 
12
12
  export function controlPanelLines(model: ControlPanelModel, tab: ControlTab, width: number, focus: ControlFocus = "messages"): string[] {
13
- const lines = [`${tab === "goal" ? "●" : "○"} Goal ${tab === "progress" ? "●" : "○"} Progress ${tab === "context" ? "●" : "○"} Context Lens`, "",
14
- ...(tab === "goal" ? [model.goal, `Goal: ${model.status}`, `Execution: ${model.execution}`, "", ...model.gaps,
13
+ const lines = tab === "goal" ? [model.goal, `Goal: ${model.status}`, `Execution: ${model.execution}`, "", ...model.gaps,
15
14
  ...model.checks, "", `Thread ${model.thread}`, `Turn ${model.turn ?? "none"}`]
16
15
  : tab === "progress" ? ["Evidence for this request", ...model.progress.map(item => `${item.label}: ${item.count}`), "",
17
16
  "Counts describe observations, not a completion percentage.", ...model.checks,
18
17
  ...model.progress.flatMap(item => item.sources.map(source => `${item.label}: ${source}`))]
19
18
  : focus === "messages" ? ["Actual model context · ←/→ change lens", ...model.context]
20
19
  : focus === "omitted" ? ["Not replayed as messages · ←/→ change lens", ...(model.omitted.length ? model.omitted : ["No omitted events in this segment."])]
21
- : ["Handoff retention and recovery · ←/→ change lens", ...(model.history.length ? model.history : ["No carried history in this segment."])])];
22
- return lines.map(line => clipText(line.replace(/[\x00-\x1f\x7f-\x9f]/g, " "), Math.max(1, width)));
20
+ : ["Handoff retention and recovery · ←/→ change lens", ...(model.history.length ? model.history : ["No carried history in this segment."])];
21
+ // Unlike status chrome, evidence is inspectable content: every character must
22
+ // remain reachable by scrolling, including long source paths and CJK (Q24).
23
+ // A one-cell pane cannot show a wide glyph; its owner shows resize feedback.
24
+ return lines.flatMap(line => wrapPlain(line.replace(/[\x00-\x1f\x7f-\x9f]/g, " "), Math.max(2, width)));
23
25
  }
@@ -0,0 +1,49 @@
1
+ /** Persistent view state only; the controller supplies evidence and owns actions (D20/Q24). */
2
+ import { controlPanelLines, type ControlFocus, type ControlPanelModel, type ControlTab } from "./control-panel.ts";
3
+ import { clipText } from "./edge.ts";
4
+ import type { Line } from "./status.ts";
5
+
6
+ const TABS: readonly ControlTab[] = ["goal", "progress", "context"];
7
+ const LENSES: readonly ControlFocus[] = ["messages", "omitted", "history"];
8
+ /** Below these assumed view-only limits, show reversible size feedback instead of unreadable controls. */
9
+ const MIN_CONTENT_COLUMNS = 16, MIN_CONTENT_ROWS = 8;
10
+
11
+ export class ControlSidebar {
12
+ model: ControlPanelModel | undefined;
13
+ tab: ControlTab = "goal";
14
+ lens: ControlFocus = "messages";
15
+ private readonly offsets = new Map<string, number>();
16
+ private shownOffset = 0;
17
+ private pageRows = 1;
18
+ private maxOffset = 0;
19
+
20
+ private get page(): string { return this.tab === "context" ? `${this.tab}:${this.lens}` : this.tab; }
21
+
22
+ changeTab(delta: number): void { this.tab = TABS[(TABS.indexOf(this.tab) + delta + TABS.length) % TABS.length]!; }
23
+ changeLens(delta: number): void { this.lens = LENSES[(LENSES.indexOf(this.lens) + delta + LENSES.length) % LENSES.length]!; }
24
+ scroll(delta: number, page = false): void {
25
+ this.offsets.set(this.page, Math.max(0, Math.min(this.maxOffset, this.shownOffset + delta * (page ? this.pageRows : 1))));
26
+ }
27
+ edge(end: boolean): void { this.offsets.set(this.page, end ? this.maxOffset : 0); }
28
+
29
+ rows(width: number, height: number, focused: boolean): readonly Line[] {
30
+ const w = Math.max(0, Math.floor(width)), h = Math.max(0, Math.floor(height));
31
+ if (w === 0 || h === 0) return [];
32
+ const row = (text: string, role: "text" | "muted" | "accent" = "text", bold = false): Line => [{ text: clipText(text, w), role, bold }];
33
+ if (w < MIN_CONTENT_COLUMNS || h < MIN_CONTENT_ROWS) {
34
+ // Do not reset the model, tab or desired scroll position: resize is reversible.
35
+ return [row(w < 6 ? "↔" : "Resize for control", "accent"), row("Esc editor · q close", "muted")].slice(0, h);
36
+ }
37
+ const tabs = TABS.map(tab => `${tab === this.tab ? "●" : "○"} ${tab === "context" ? "Context Lens" : tab === "goal" ? "Goal" : "Progress"}`);
38
+ const heading = [row(`Control panel · ${focused ? "focused" : "F6 focus"}`, "accent", true), row(`${tabs[0]} ${tabs[1]}`), row(tabs[2]!)];
39
+ const hints = ["Tab sections · ←→ lenses", "r refresh · i stop · a accept", "Esc editor · q close · F6"];
40
+ this.pageRows = Math.max(1, h - heading.length - hints.length - 1);
41
+ const body = this.model ? controlPanelLines(this.model, this.tab, w, this.lens) : ["Evidence unavailable.", "Press r to refresh."];
42
+ this.maxOffset = Math.max(0, body.length - this.pageRows);
43
+ this.shownOffset = Math.min(this.maxOffset, this.offsets.get(this.page) ?? 0);
44
+ const shown = body.slice(this.shownOffset, this.shownOffset + this.pageRows).map(text => row(text));
45
+ while (shown.length < this.pageRows) shown.push(row(""));
46
+ const range = `${this.shownOffset + 1}–${Math.min(body.length, this.shownOffset + this.pageRows)}/${body.length}`;
47
+ return [...heading, ...shown, row(`↑↓ PgUp/PgDn · ${range}`, "muted"), ...hints.map(text => row(text, "muted"))];
48
+ }
49
+ }
@@ -23,9 +23,11 @@ import { Canvas } from "./canvas.ts";
23
23
  import type { UiColors } from "./colors.ts";
24
24
  import { Composer, draftMaxLines } from "./composer.ts";
25
25
  import { DialogHost } from "./dialog.ts";
26
+ import type { ControlPanelModel, ControlTab } from "./control-panel.ts";
27
+ import { ControlSidebar } from "./control-sidebar.ts";
26
28
  import { clipSegs, edgeLine, segsWidth, type Label, type Seg } from "./edge.ts";
27
- import type { Bindings } from "./keys.ts";
28
- import { screenLayout, type ScreenLayout } from "./layout.ts";
29
+ import type { Bindings, KeyPress } from "./keys.ts";
30
+ import { screenLayout, sidebarContentWidth, type ScreenLayout } from "./layout.ts";
29
31
  import { greetingLines, mascotFrame, MASCOT_FRAME_MS, MASCOT_FRAMES, type Greeting } from "./mascot.ts";
30
32
  import { MessageList, type MessageFeatures } from "./messages.ts";
31
33
  import { Motion } from "./motion.ts";
@@ -84,6 +86,12 @@ export class Screen {
84
86
  private readonly bottomEdge: Canvas;
85
87
  private readonly sidebar: BoxRenderable;
86
88
  private readonly sidebarRows: Canvas;
89
+ private readonly middle: BoxRenderable;
90
+ private readonly messagesArea: BoxRenderable;
91
+ private readonly controlView = new ControlSidebar();
92
+ private controlShown = false;
93
+ private controlHasFocus = false;
94
+ onControlAction: ((action: "refresh" | "interrupt" | "accept") => void) | undefined;
87
95
  private shape: ScreenLayout;
88
96
  private sidebarWanted = true;
89
97
  private status: StatusModel | undefined;
@@ -118,9 +126,11 @@ export class Screen {
118
126
  const root = new BoxRenderable(renderer, { id: "root", flexDirection: "column", width: "100%", height: "100%" });
119
127
  this.topbar = new Canvas(renderer, { id: "topbar", height: 1, flexShrink: 0, marginLeft: 1, marginRight: 1, colors });
120
128
  const middle = new BoxRenderable(renderer, { id: "middle", flexDirection: "row", flexGrow: 1, flexShrink: 1 });
129
+ this.middle = middle;
121
130
  const main = new BoxRenderable(renderer, { id: "main", flexDirection: "column", flexGrow: 1, flexShrink: 1 });
122
131
  this.greeting = new Canvas(renderer, { id: "greeting", height: 3, flexShrink: 0, marginTop: 1, marginLeft: 3, marginBottom: 1, colors });
123
132
  const area = new BoxRenderable(renderer, { id: "messages-area", flexDirection: "column", flexGrow: 1, flexShrink: 1 });
133
+ this.messagesArea = area;
124
134
  this.messages = new MessageList(renderer, colors, features, this.glyph);
125
135
  area.add(this.messages.scroll);
126
136
  this.pill = new Canvas(renderer, { id: "pill", position: "absolute", zIndex: 40, visible: false, height: 1, width: 1, bottom: 0, right: 3, colors });
@@ -140,16 +150,26 @@ export class Screen {
140
150
  this.bottomEdge = new Canvas(renderer, { id: "dock-bottom", height: 1, flexShrink: 0, colors });
141
151
  this.dock.add(this.topEdge); this.dock.add(this.body); this.dock.add(this.bottomEdge);
142
152
  main.add(this.greeting); main.add(area); main.add(this.above); main.add(this.dock);
143
- this.sidebar = new BoxRenderable(renderer, { id: "sidebar", flexShrink: 0, border: ["left"], borderColor: colors.line, paddingLeft: 1, paddingRight: 1, paddingTop: 1, flexDirection: "column" });
153
+ this.sidebar = new BoxRenderable(renderer, { id: "sidebar", flexShrink: 0, border: ["left"], borderColor: colors.line, paddingLeft: 1, paddingRight: 1, paddingTop: 1, flexDirection: "column", overflow: "hidden", backgroundColor: colors.background });
144
154
  this.sidebarRows = new Canvas(renderer, { id: "sidebar-rows", flexGrow: 1, colors });
155
+ this.sidebarRows.onSizeChange = () => this.paintStatus();
156
+ this.sidebar.onMouseDown = () => { if (this.controlShown && !this.dialogs.open) this.focusControl(); };
145
157
  this.sidebar.add(this.sidebarRows);
146
158
  middle.add(main); middle.add(this.sidebar);
147
159
  root.add(this.topbar); root.add(middle);
148
160
  renderer.root.add(root);
149
161
  this.dialogs = new DialogHost(renderer, colors, bindings, slot, () => this.dialogRows(), this.glyph);
150
162
  this.dialogs.onOpened = () => { this.composer.setCompletion(undefined); this.composer.area.blur(); this.composer.row.visible = false; this.paintAttachments(); this.layout(); };
151
- this.dialogs.onClosed = () => { this.composer.row.visible = true; this.paintAttachments(); this.layout(); this.composer.focus(); };
163
+ this.dialogs.onClosed = () => {
164
+ this.composer.row.visible = true; this.paintAttachments(); this.layout();
165
+ // Dialogs temporarily own input; restoring the existing owner preserves a
166
+ // control-initiated approval without replacing or focusing the editor (Q24).
167
+ if (this.controlShown && this.controlHasFocus) this.focusControl(); else this.composer.focus();
168
+ };
152
169
  this.dialogs.onChrome = () => { this.paintEdges(); this.syncActivity(); };
170
+ // Native mouse selection can focus the textarea without going through an
171
+ // app shortcut. The actual input owner must also release panel actions (Q24).
172
+ this.composer.area.on("focused", () => { this.controlHasFocus = false; this.paintStatus(); });
153
173
  this.composer.onResize = () => this.paintAbove();
154
174
  this.composer.onOverflow = () => this.paintEdges();
155
175
  this.composer.onChange = () => this.paintGreeting();
@@ -164,6 +184,8 @@ export class Screen {
164
184
  get width(): number { return this.renderer.width; }
165
185
  get tier(): ScreenLayout["tier"] { return this.shape.tier; }
166
186
  get sidebarVisible(): boolean { return this.shape.tier === "sidebar"; }
187
+ get controlOpen(): boolean { return this.controlShown; }
188
+ get controlFocused(): boolean { return this.controlShown && this.controlHasFocus && !this.dialogs.open; }
167
189
  /** An empty session (the greeting may still be hidden by a draft). */
168
190
  get homeVisible(): boolean { return this.home !== undefined; }
169
191
  get greetingVisible(): boolean { return this.greeting.visible; }
@@ -184,9 +206,11 @@ export class Screen {
184
206
  this.renderer.setBackgroundColor(colors.background);
185
207
  for (const canvas of [this.topbar, this.greeting, this.pill, this.completion, this.queueRows, this.widgetRows, this.topEdge, this.bottomEdge, this.sidebarRows]) canvas.setColors(colors);
186
208
  this.sidebar.borderColor = colors.line;
209
+ this.sidebar.backgroundColor = colors.background;
187
210
  this.messages.setColors(colors);
188
211
  this.composer.setColors(colors);
189
212
  this.dialogs.setColors(colors);
213
+ this.paintStatus();
190
214
  this.paintEdges();
191
215
  }
192
216
 
@@ -197,15 +221,77 @@ export class Screen {
197
221
  this.syncActivity();
198
222
  }
199
223
 
200
- toggleSidebar(): void { this.sidebarWanted = !this.sidebarWanted; this.layout(); }
224
+ toggleSidebar(): void {
225
+ if (this.controlShown) { this.closeControl(); return; }
226
+ this.sidebarWanted = !this.sidebarWanted; this.layout();
227
+ }
228
+
229
+ // --------------------------------------------------------------- controls
230
+ showControl(model: ControlPanelModel | undefined, tab: ControlTab = "goal"): void {
231
+ this.controlView.model = model; this.controlView.tab = tab; this.controlShown = true;
232
+ this.composer.setCompletion(undefined);
233
+ this.focusControl(); this.layout();
234
+ }
235
+
236
+ /** Snapshot updates never open the panel or take the user's input focus. */
237
+ setControlModel(model: ControlPanelModel | undefined): void { this.controlView.model = model; if (this.controlShown) this.paintStatus(); }
238
+
239
+ closeControl(): void { this.controlShown = false; this.focusComposer(); this.layout(); }
240
+
241
+ focusComposer(): void {
242
+ this.controlHasFocus = false;
243
+ if (!this.dialogs.open) this.composer.focus();
244
+ this.paintStatus();
245
+ }
246
+
247
+ private focusControl(): void {
248
+ this.controlHasFocus = true;
249
+ if (!this.dialogs.open) this.composer.area.blur();
250
+ this.paintStatus();
251
+ }
252
+
253
+ handleControlKey(key: KeyPress): boolean {
254
+ if (!this.controlShown || this.dialogs.open) return false;
255
+ if (key.chord === "f6") { if (this.controlHasFocus) this.focusComposer(); else this.focusControl(); return true; }
256
+ if (!this.controlHasFocus) return false;
257
+ if (key.chord === "escape") { this.focusComposer(); return true; }
258
+ if (key.chord === "q") { this.closeControl(); return true; }
259
+ if (key.chord === "tab" || key.chord === "shift+tab") this.controlView.changeTab(key.shift ? -1 : 1);
260
+ else if (key.chord === "left" || key.chord === "right") {
261
+ const step = key.chord === "left" ? -1 : 1;
262
+ if (this.controlView.tab === "context") this.controlView.changeLens(step); else this.controlView.changeTab(step);
263
+ } else if (key.chord === "up" || key.chord === "down") this.controlView.scroll(key.chord === "up" ? -1 : 1);
264
+ else if (key.chord === "pageup" || key.chord === "pagedown") this.controlView.scroll(key.chord === "pageup" ? -1 : 1, true);
265
+ else if (key.chord === "home" || key.chord === "end") this.controlView.edge(key.chord === "end");
266
+ else if (key.chord === "r" || key.chord === "i" || key.chord === "a") this.onControlAction?.(key.chord === "r" ? "refresh" : key.chord === "i" ? "interrupt" : "accept");
267
+ else {
268
+ // Let the same key reach the original editor after handing focus back.
269
+ // Modifier shortcuts remain the app's; panel navigation never eats typing.
270
+ if (key.text || key.chord === "enter") this.focusComposer();
271
+ return false;
272
+ }
273
+ this.paintStatus();
274
+ return true;
275
+ }
201
276
 
202
277
  // ------------------------------------------------------------------ layout
203
278
  private layout(): void {
204
279
  const w = this.renderer.width, h = this.renderer.height;
205
- this.shape = screenLayout(w, h, this.sidebarWanted);
280
+ this.shape = screenLayout(w, h, this.sidebarWanted || this.controlShown);
206
281
  this.topbar.visible = this.shape.topbar;
207
- this.sidebar.visible = this.shape.tier === "sidebar";
208
- if (this.sidebar.visible) this.sidebar.width = this.shape.sidebarWidth;
282
+ const drawer = this.controlShown && this.shape.tier !== "sidebar";
283
+ // One sidebar changes parent at the responsive boundary. The drawer belongs
284
+ // to the transcript viewport, so even a growing composer cannot be covered.
285
+ const parent = drawer ? this.messagesArea : this.middle;
286
+ if (this.sidebar.parent !== parent) { this.sidebar.parent?.remove(this.sidebar); parent.add(this.sidebar); }
287
+ this.sidebar.position = drawer ? "absolute" : "relative";
288
+ this.sidebar.top = drawer ? 0 : undefined; this.sidebar.bottom = drawer ? 0 : undefined; this.sidebar.right = drawer ? 0 : undefined;
289
+ this.sidebar.zIndex = drawer ? 50 : 0;
290
+ this.sidebar.visible = (this.shape.tier === "sidebar" || drawer) && w > 0 && h > 3;
291
+ if (this.sidebar.visible) this.sidebar.width = drawer ? Math.min(w, sidebarContentWidth(w) + 1) : this.shape.sidebarWidth;
292
+ const compact = drawer && w < 6;
293
+ this.sidebar.border = compact ? [] : ["left"];
294
+ this.sidebar.paddingLeft = compact ? 0 : 1; this.sidebar.paddingRight = compact ? 0 : 1; this.sidebar.paddingTop = compact ? 0 : 1;
209
295
  // The draft may grow while the transcript keeps its minimum and the box keeps its edges.
210
296
  this.composer.setMaxLines(Math.min(draftMaxLines(h), h - (this.shape.topbar ? 1 : 0) - 2 - MIN_TRANSCRIPT_ROWS - this.aboveRows()));
211
297
  this.dialogs.layout();
@@ -231,8 +317,14 @@ export class Screen {
231
317
 
232
318
  private paintStatus(): void {
233
319
  const model = this.status;
234
- if (!model) return;
235
- if (this.topbar.visible) this.topbar.setRows([topbarRow(model, Math.max(1, this.renderer.width - 2))]);
320
+ if (model && this.topbar.visible) this.topbar.setRows([topbarRow(model, Math.max(1, this.renderer.width - 2))]);
321
+ if (this.controlShown) {
322
+ this.sidebar.borderColor = this.controlFocused ? this.colors.accent : this.colors.line;
323
+ this.sidebarRows.setRows(this.controlView.rows(this.sidebarRows.width, this.sidebarRows.height, this.controlFocused));
324
+ return;
325
+ }
326
+ this.sidebar.borderColor = this.colors.line;
327
+ if (!model) { this.sidebarRows.setRows([]); return; }
236
328
  if (this.sidebar.visible) {
237
329
  const width = Math.max(1, this.shape.sidebarWidth - 3);
238
330
  this.sidebarRows.setRows(sidebarLines(model, width, Math.max(0, this.renderer.height - 1)));
@@ -270,6 +362,10 @@ export class Screen {
270
362
  if (dialog) {
271
363
  left = [{ segs: [{ text: this.dialogs.title, role: "text", bold: true }], priority: 0, min: 8 }];
272
364
  if (this.dialogs.edgeHint) right.push({ segs: [{ text: this.dialogs.edgeHint, role: "muted" }], priority: 3, min: 10 });
365
+ } else if (this.controlShown && this.renderer.height <= 3) {
366
+ // No transcript rows remain for a drawer. Keep its state and explain how
367
+ // to recover on the existing edge, without putting control content in it.
368
+ left = [{ segs: [{ text: "Resize for control", role: "accent" }], priority: 0, min: 1 }];
273
369
  } else if (d.busy) {
274
370
  const seconds = Math.max(0, Math.floor((Date.now() - this.busySince) / 1000));
275
371
  left = [{ segs: [{ text: "●", role: "accent", spin: true }, { text: ` ${d.activity}`, role: "muted" }, { text: ` ${seconds}s`, role: "muted" }], priority: 0, min: 1, shrink: 1 }];
@@ -379,7 +475,7 @@ export class Screen {
379
475
  }
380
476
 
381
477
  private paintGreeting(): void {
382
- const show = this.home !== undefined && !this.composer.text && !this.dialogs.open && this.renderer.height >= GREETING_MIN_ROWS;
478
+ const show = this.home !== undefined && !this.controlShown && !this.composer.text && !this.dialogs.open && this.renderer.height >= GREETING_MIN_ROWS;
383
479
  this.greeting.visible = show;
384
480
  if (!show) { if (this.entrance.running) this.finishEntrance(); return; }
385
481
  // The entrance plays once per screen, when the greeting first shows on a focused terminal.