@visual-z/xeno 0.3.0 → 0.5.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 (60) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +55 -16
  3. package/docs/app-server.md +86 -0
  4. package/docs/install.md +1 -1
  5. package/docs/providers.md +20 -6
  6. package/package.json +3 -2
  7. package/src/apps/app-server.ts +28 -0
  8. package/src/apps/cli.ts +25 -10
  9. package/src/apps/commands.ts +10 -6
  10. package/src/apps/completions.ts +15 -13
  11. package/src/apps/control-transport.ts +89 -0
  12. package/src/apps/model-catalog.ts +2 -1
  13. package/src/apps/preferences.ts +20 -0
  14. package/src/apps/provider-connect.ts +4 -4
  15. package/src/apps/shell.ts +73 -50
  16. package/src/apps/startup-input.ts +7 -2
  17. package/src/apps/startup.ts +52 -19
  18. package/src/apps/ui/app.ts +210 -56
  19. package/src/apps/ui/control-model.ts +26 -0
  20. package/src/apps/ui/surface.ts +2 -0
  21. package/src/core/completion-action.ts +20 -0
  22. package/src/core/completion-review.ts +38 -29
  23. package/src/core/control-protocol.ts +62 -0
  24. package/src/core/control-view.ts +42 -0
  25. package/src/core/event-validation.ts +1 -1
  26. package/src/core/events.ts +2 -0
  27. package/src/core/harness-diagnostics.ts +206 -0
  28. package/src/core/model-preferences.ts +28 -0
  29. package/src/core/oauth-fields.ts +44 -0
  30. package/src/core/permissions.ts +13 -7
  31. package/src/core/program-status.ts +31 -0
  32. package/src/core/project.ts +25 -1
  33. package/src/core/request-progress.ts +87 -0
  34. package/src/core/request-state.ts +114 -0
  35. package/src/core/settings.ts +8 -1
  36. package/src/core/tool-input.ts +6 -0
  37. package/src/runtime/auth.ts +10 -3
  38. package/src/runtime/catalog.ts +21 -4
  39. package/src/runtime/control-connection.ts +104 -0
  40. package/src/runtime/harness-eval.ts +3 -1
  41. package/src/runtime/loop.ts +72 -19
  42. package/src/runtime/model.ts +9 -0
  43. package/src/runtime/native-eval.ts +6 -3
  44. package/src/runtime/oauth.ts +80 -3
  45. package/src/runtime/pi-provider.ts +169 -12
  46. package/src/runtime/program-status.ts +38 -0
  47. package/src/runtime/provider-connect.ts +2 -2
  48. package/src/runtime/provider-presets.ts +1 -1
  49. package/src/runtime/provider-request.ts +13 -2
  50. package/src/runtime/provider.ts +23 -9
  51. package/src/runtime/session-control.ts +198 -0
  52. package/src/runtime/tools.ts +11 -9
  53. package/src/store/resources.ts +13 -22
  54. package/src/store/user-settings.ts +30 -0
  55. package/src/tui/ui/composer.ts +3 -2
  56. package/src/tui/ui/control-panel.ts +25 -0
  57. package/src/tui/ui/control-sidebar.ts +49 -0
  58. package/src/tui/ui/mascot.ts +1 -1
  59. package/src/tui/ui/screen.ts +107 -11
  60. package/src/tui/ui/status.ts +8 -0
@@ -370,7 +370,7 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
370
370
  description:
371
371
  "Read a file. Returns the contents with 1-indexed line numbers. " +
372
372
  "Optionally limit to an inclusive line range. Reports total lines and the next range; end beyond EOF is clamped. " +
373
- "Returns read_handle and whole-file sha256 for version checks. Long lines and large previews retain an immutable artifact. Line ranges describe the selection; the observation receipt describes exactly what was visible. An unavailable artifact-recovery capability leaves a gap, not proof of full coverage.",
373
+ "Session remembers the whole-file version for later changes; no digest copying is needed. Returns read_handle and whole-file sha256 for selecting an explicit observation. Long lines and large previews retain an immutable artifact. Line ranges describe the selection; the observation receipt describes exactly what was visible. An unavailable artifact-recovery capability leaves a gap, not proof of full coverage.",
374
374
  input_schema: {
375
375
  type: "object",
376
376
  properties: {
@@ -384,14 +384,14 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
384
384
  {
385
385
  name: "write",
386
386
  access: "write", execution: "exclusive", branch: "write", run: toolWrite,
387
- description: "Write a file, creating parent directories. Replaces existing content. Pass read_handle or expected_sha256 for an explicit version check; otherwise Session uses its last observed version. create_only requires absence. A conflict requires a fresh read. Identical content makes no change.",
387
+ description: "Write a file, creating parent directories. Replaces existing content. Normally omit read_handle and expected_sha256: Session checks its latest observed version. Without a prior observation, omission has no stale-version protection. To select a specific observation instead, pass its exact version receipt. create_only requires absence. A conflict requires a fresh read. Identical content makes no change.",
388
388
  input_schema: {
389
389
  type: "object",
390
390
  properties: {
391
391
  path: { type: "string" },
392
392
  content: { type: "string" },
393
- read_handle: { type: "string", description: "Descriptor SHA from an observation of this file, workspace and host" },
394
- expected_sha256: { type: "string", description: "Expected whole-file raw-byte SHA-256" },
393
+ read_handle: { type: "string", description: "Optional explicit descriptor SHA for this file, workspace and host; normally omit" },
394
+ expected_sha256: { type: "string", description: "Optional explicit whole-file raw-byte SHA-256; normally omit" },
395
395
  create_only: { type: "boolean", description: "Require that the target does not exist; excludes read_handle and expected_sha256" },
396
396
  },
397
397
  required: ["path", "content"],
@@ -400,14 +400,16 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
400
400
  {
401
401
  name: "edit",
402
402
  example: { path: "src/main.ts", old_string: "const size = 1;", new_string: "const size = 2;" },
403
- guidance: [{ requires: ["read"], text: "Use read to obtain current text and a version receipt before an edit." }],
403
+ guidance: [{ requires: ["read"], text: "Use read to obtain current text before an edit; Session remembers its version automatically." }],
404
404
  access: "write", execution: "exclusive", branch: "write", run: toolEdit,
405
405
  description:
406
- "Replace exact text in a file. old_string must match once unless replace_all is set; copy it from observed file content, " +
406
+ "Replace exact text in a file. Normally omit read_handle and expected_sha256: Session checks its latest observed version. " +
407
+ "Without a prior observation, omission has no stale-version protection. To select a specific observation instead, pass its exact version receipt. " +
408
+ "old_string must match once unless replace_all is set; copy it from observed file content, " +
407
409
  "including indentation (line numbers are not part of the file). For several changes to one file pass edits[], " +
408
410
  "each matched against the original; all apply or none do. If there is no exact match, a unique match that differs only in " +
409
411
  "trailing whitespace, quote or dash style is applied and flagged fuzzy. A failure shows the closest text in the file. " +
410
- "Pass read_handle or expected_sha256 from a file observation to reject stale edits; when omitted, Session uses its last observed version. A conflict requires a fresh observation. A partial read handle checks the whole file version, not permission to edit unseen lines.",
412
+ "A conflict requires a fresh observation. A partial read handle checks the whole file version, not permission to edit unseen lines.",
411
413
  input_schema: {
412
414
  type: "object",
413
415
  properties: {
@@ -419,8 +421,8 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
419
421
  type: "object", required: ["old_string", "new_string"], properties: {
420
422
  old_string: { type: "string", minLength: 1 }, new_string: { type: "string" }, replace_all: { type: "boolean" },
421
423
  } } },
422
- expected_sha256: { type: "string", description: "Observed whole-file version; mismatch leaves the file unchanged" },
423
- read_handle: { type: "string", description: "Descriptor SHA from an observation of this file, workspace and host" },
424
+ expected_sha256: { type: "string", description: "Optional explicit whole-file raw-byte SHA-256; normally omit" },
425
+ read_handle: { type: "string", description: "Optional explicit descriptor SHA for this file, workspace and host; normally omit" },
424
426
  },
425
427
  required: ["path"],
426
428
  },
@@ -2,8 +2,8 @@
2
2
  * Discover and read UI resources: settings, theme, keybindings, skills,
3
3
  * prompt templates.
4
4
  *
5
- * I/O lives here; validation is core. A corrupt file is a diagnostic plus
6
- * a fallback, never a throw -- startup has to survive a missing comma.
5
+ * I/O lives here; validation is core. Reads diagnose corrupt files and fall
6
+ * back so startup survives a missing comma. Writes preserve them and report failure.
7
7
  *
8
8
  * Project-level files follow the same trust rule as `.xeno/settings.json`.
9
9
  * Theme and keymap are UI state and are not logged.
@@ -12,7 +12,9 @@
12
12
  import { readdir, readFile } from "node:fs/promises";
13
13
  import { watch } from "node:fs";
14
14
  import { basename, dirname, join } from "node:path";
15
- import { configDir, readJsonFile, writeJsonFile, settingsPath } from "./config.ts";
15
+ import { configDir, readJsonFile } from "./config.ts";
16
+ import { editUserSettings } from "./user-settings.ts";
17
+ import { mergePreferenceJson } from "../core/model-preferences.ts";
16
18
  import { discoverResources, themeResourcePath, type ResourceIndex } from "./resource-index.ts";
17
19
  import { parseSkill, type Skill } from "../core/skills.ts";
18
20
  import { parsePromptTemplate, type PromptTemplate } from "../core/prompts.ts";
@@ -29,7 +31,6 @@ import {
29
31
  import {
30
32
  defaultSettings,
31
33
  mergeSettings,
32
- settingsToWrite,
33
34
  type Settings,
34
35
  } from "../core/settings.ts";
35
36
 
@@ -90,28 +91,18 @@ export function selectedThemePath(index: ResourceIndex, name: string, noThemes =
90
91
  }
91
92
 
92
93
  export async function writeUserSettings(overrides: Partial<Settings>): Promise<void> {
93
- const { value, error } = await readJsonFile<unknown>(settingsPath());
94
- const prev =
95
- error === null && typeof value === "object" && value !== null && !Array.isArray(value)
96
- ? (value as { readonly [k: string]: unknown })
97
- : {};
98
- const next = { ...prev, ...settingsToWrite(overrides),
99
- ...(overrides.terminal ? { terminal: { ...(typeof prev["terminal"] === "object" ? prev["terminal"] : {}), ...overrides.terminal } } : {}),
100
- ...(overrides.modelThinkingLevels ? { modelThinkingLevels: { ...(typeof prev["modelThinkingLevels"] === "object" ? prev["modelThinkingLevels"] : {}), ...overrides.modelThinkingLevels } } : {}),
101
- ...Object.fromEntries((["images", "markdown"] as const).filter(key => overrides[key]).map(key => [key, { ...(typeof prev[key] === "object" ? prev[key] : {}), ...overrides[key] }])),
102
- };
103
- await writeJsonFile(settingsPath(), next, false);
94
+ const patch = structuredClone(overrides);
95
+ await editUserSettings(previous => mergePreferenceJson(previous, patch));
104
96
  }
105
97
 
106
98
  /** Remove only the user's override; project defaults and unrelated keys retain their ownership. */
107
99
  export async function removeModelThinkingOverride(identity: string): Promise<void> {
108
- const { value, error } = await readJsonFile<Record<string, unknown>>(settingsPath());
109
- if (error) throw new Error(error);
110
- if (!value || typeof value !== "object" || Array.isArray(value)) return;
111
- const levels = value["modelThinkingLevels"];
112
- if (!levels || typeof levels !== "object" || Array.isArray(levels)) return;
113
- const next = Object.fromEntries(Object.entries(levels).filter(([key]) => key !== identity));
114
- await writeJsonFile(settingsPath(), { ...value, modelThinkingLevels: next }, false);
100
+ await editUserSettings(previous => {
101
+ const levels = previous["modelThinkingLevels"];
102
+ if (!levels || typeof levels !== "object" || Array.isArray(levels)) return null;
103
+ const next = Object.fromEntries(Object.entries(levels).filter(([key]) => key !== identity));
104
+ return { ...previous, modelThinkingLevels: next };
105
+ });
115
106
  }
116
107
 
117
108
  export async function resolveTheme(spec: string, diagnostics: string[]): Promise<Theme> {
@@ -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
+ }
@@ -16,6 +16,7 @@ import { draftRows, type DraftRows } from "./draft.ts";
16
16
  import type { ListItem } from "./list-model.ts";
17
17
 
18
18
  export interface Completion {
19
+ readonly kind: "command" | "argument" | "path";
19
20
  /** Replace text[start, end) with an item's value. */
20
21
  readonly start: number;
21
22
  readonly end: number;
@@ -89,8 +90,8 @@ export class Composer {
89
90
  setAtoms(spans: (text: string) => readonly DraftAtom[]): void { this.area.atoms = spans; }
90
91
  get completing(): boolean { return this.completion !== undefined && this.completion.items.length > 0; }
91
92
  /** What the completion list shows (screen draws it). */
92
- get completionView(): { readonly items: readonly ListItem[]; readonly selected: number } | undefined {
93
- return this.completion ? { items: this.completion.items, selected: this.completionIndex } : undefined;
93
+ get completionView(): { readonly kind: Completion["kind"]; readonly items: readonly ListItem[]; readonly selected: number } | undefined {
94
+ return this.completion ? { kind: this.completion.kind, items: this.completion.items, selected: this.completionIndex } : undefined;
94
95
  }
95
96
  get historyEntries(): readonly string[] { return this.history; }
96
97
  get rows(): number { return Math.max(1, this.area.height); }
@@ -0,0 +1,25 @@
1
+ /** View-only data: apps owns the projection and actions, as required by D20. */
2
+ import { wrapPlain } from "../diff.ts";
3
+
4
+ export type ControlPanelModel = {
5
+ thread: string; turn: string | null; goal: string; status: string; execution: string; gaps: readonly string[];
6
+ progress: readonly { label: string; count: number; sources: readonly string[] }[]; checks: readonly string[];
7
+ context: readonly string[]; omitted: readonly string[]; history: readonly string[];
8
+ };
9
+ export type ControlTab = "goal" | "progress" | "context";
10
+ export type ControlFocus = "messages" | "omitted" | "history";
11
+
12
+ export function controlPanelLines(model: ControlPanelModel, tab: ControlTab, width: number, focus: ControlFocus = "messages"): string[] {
13
+ const lines = tab === "goal" ? [model.goal, `Goal: ${model.status}`, `Execution: ${model.execution}`, "", ...model.gaps,
14
+ ...model.checks, "", `Thread ${model.thread}`, `Turn ${model.turn ?? "none"}`]
15
+ : tab === "progress" ? ["Evidence for this request", ...model.progress.map(item => `${item.label}: ${item.count}`), "",
16
+ "Counts describe observations, not a completion percentage.", ...model.checks,
17
+ ...model.progress.flatMap(item => item.sources.map(source => `${item.label}: ${source}`))]
18
+ : focus === "messages" ? ["Actual model context · ←/→ change lens", ...model.context]
19
+ : focus === "omitted" ? ["Not replayed as messages · ←/→ change lens", ...(model.omitted.length ? model.omitted : ["No omitted events in this segment."])]
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)));
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
+ }
@@ -61,7 +61,7 @@ export function greetingLines(greeting: Greeting, frame: readonly string[] = MAS
61
61
  [art(0), gap, { text: "xeno", role: "text", bold: true }, { text: ` ${greeting.version}`, role: "muted" }],
62
62
  [art(1), gap, ...(greeting.model
63
63
  ? [{ text: greeting.model, role: "text" as const }, { text: ` · ${greeting.thinking}`, role: "muted" as const }]
64
- : [{ text: "No provider connected yet · /login", role: "warning" as const }])],
64
+ : [{ text: "No provider connected yet · /provider", role: "warning" as const }])],
65
65
  [art(2), gap, { text: greeting.workspace, role: "muted" }, ...(greeting.branch ? [{ text: ` ⎇ ${greeting.branch}`, role: "muted" as const }] : [])],
66
66
  ];
67
67
  }
@@ -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.
@@ -30,6 +30,7 @@ export interface StatusModel {
30
30
  readonly version: string;
31
31
  /** `/name`, else the first prompt; null for a session with neither. */
32
32
  readonly title: string | null;
33
+ readonly goal?: { readonly text: string; readonly status: string; readonly execution: string; readonly observations: number; readonly retries: number };
33
34
  readonly workspace: string;
34
35
  readonly workspaceShort: string;
35
36
  readonly branch: string | null;
@@ -176,6 +177,13 @@ function sidebarBlocks(model: StatusModel, w: number): Block[] {
176
177
  ...(model.branch ? [[{ text: "⎇ ", role: "muted" }, { text: model.branch, role: "muted" }] as Line] : []),
177
178
  ] });
178
179
  const context = model.context;
180
+ if (model.goal) blocks.push({ list: false, items: [], head: [
181
+ [{ text: "Goal", role: "text", bold: true }, { text: ` · ${model.goal.status}`, role: model.goal.status === "verified" ? "success" : "warning" }],
182
+ ...wrap(firstLine(model.goal.text), w, 2).map(text => [{ text, role: "text" }] as Line),
183
+ [{ text: model.goal.execution, role: "muted" }],
184
+ [{ text: `${model.goal.observations} observations · ${model.goal.retries} retries`, role: "muted" }],
185
+ [{ text: "/control · goal, evidence, context", role: "muted" }],
186
+ ] });
179
187
  if (context) {
180
188
  const share = contextShare(context), est = context.estimated ? "~" : "";
181
189
  const filled = share === undefined ? 0 : Math.max(share > 0 ? 1 : 0, Math.round(share * w));