@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.
- package/CHANGELOG.md +20 -0
- package/README.md +41 -10
- package/docs/app-server.md +35 -4
- package/docs/foundations.md +88 -0
- package/docs/install.md +23 -2
- package/docs/providers.md +4 -0
- package/docs/research/foundation-clients-2026-10-09.md +27 -0
- package/docs/research/foundation-editing-2026-10-09.md +56 -0
- package/docs/research/foundation-network-2026-10-09.md +37 -0
- package/docs/research/foundation-workflow-2026-10-09.md +37 -0
- package/package.json +10 -2
- package/src/apps/acp-server.ts +41 -0
- package/src/apps/agent-profiles.ts +19 -0
- package/src/apps/app-server.ts +4 -2
- package/src/apps/cli.ts +18 -2
- package/src/apps/commands.ts +20 -0
- package/src/apps/control-transport.ts +3 -2
- package/src/apps/model-catalog.ts +57 -31
- package/src/apps/preferences.ts +20 -0
- package/src/apps/question-ui.ts +27 -0
- package/src/apps/resources.ts +63 -25
- package/src/apps/review.ts +109 -0
- package/src/apps/session-hub.ts +84 -0
- package/src/apps/shell.ts +88 -30
- package/src/apps/startup-input.ts +7 -2
- package/src/apps/startup.ts +180 -25
- package/src/apps/ui/app.ts +74 -91
- package/src/apps/ui/surface.ts +1 -0
- package/src/apps/update-check.ts +71 -0
- package/src/apps/workflow.ts +58 -0
- package/src/core/agent-profile.ts +130 -0
- package/src/core/completion-review.ts +13 -4
- package/src/core/control-protocol.ts +8 -1
- package/src/core/file-precondition.ts +6 -0
- package/src/core/lsp-framing.ts +34 -0
- package/src/core/mcp-config.ts +68 -0
- package/src/core/model-preferences.ts +28 -0
- package/src/core/patch.ts +130 -0
- package/src/core/permissions.ts +156 -11
- package/src/core/question.ts +94 -0
- package/src/core/review.ts +68 -0
- package/src/core/settings.ts +1 -1
- package/src/core/update-check.ts +24 -5
- package/src/core/web-content.ts +80 -0
- package/src/core/workflow-mode.ts +34 -0
- package/src/runtime/acp-agent.ts +289 -0
- package/src/runtime/agent-profiles.ts +21 -0
- package/src/runtime/catalog.ts +10 -3
- package/src/runtime/control-client.ts +149 -0
- package/src/runtime/control-connection.ts +39 -4
- package/src/runtime/extensions.ts +20 -2
- package/src/runtime/formatter-tool.ts +38 -0
- package/src/runtime/formatter.ts +50 -0
- package/src/runtime/foundation.ts +78 -0
- package/src/runtime/integrations.ts +8 -2
- package/src/runtime/loop.ts +68 -10
- package/src/runtime/lsp-client.ts +201 -0
- package/src/runtime/mcp-client.ts +264 -0
- package/src/runtime/mcp-oauth.ts +168 -0
- package/src/runtime/mcp-schema.ts +51 -0
- package/src/runtime/oauth.ts +1 -1
- package/src/runtime/output-schema.ts +26 -0
- package/src/runtime/patch-tool.ts +38 -0
- package/src/runtime/permission-resources.ts +11 -0
- package/src/runtime/provider.ts +2 -0
- package/src/runtime/question-tool.ts +62 -0
- package/src/runtime/review-tool.ts +48 -0
- package/src/runtime/session-control.ts +60 -2
- package/src/runtime/session-hub.ts +85 -0
- package/src/runtime/tool-discovery.ts +4 -3
- package/src/runtime/tools.ts +48 -16
- package/src/runtime/update-check.ts +3 -2
- package/src/runtime/web-http.ts +56 -0
- package/src/runtime/web-tools.ts +88 -0
- package/src/runtime/workspace-lifecycle.ts +14 -1
- package/src/store/agent-profile-files.ts +13 -0
- package/src/store/context-files.ts +77 -1
- package/src/store/file-checkpoints.ts +5 -4
- package/src/store/foundation-config.ts +24 -0
- package/src/store/lsp-document.ts +30 -0
- package/src/store/mcp-credentials.ts +85 -0
- package/src/store/patch.ts +92 -0
- package/src/store/permission-paths.ts +34 -0
- package/src/store/resources.ts +13 -22
- package/src/store/review.ts +109 -0
- package/src/store/search.ts +48 -9
- package/src/store/user-settings.ts +30 -0
- package/src/tui/ui/control-panel.ts +7 -5
- package/src/tui/ui/control-sidebar.ts +49 -0
- 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 {
|
|
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 =
|
|
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
|
-
|
|
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
|
+
}
|
package/src/tui/ui/screen.ts
CHANGED
|
@@ -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 = () => {
|
|
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 {
|
|
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.
|
|
208
|
-
|
|
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 (
|
|
235
|
-
if (this.
|
|
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.
|