@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.
- package/CHANGELOG.md +19 -0
- package/README.md +55 -16
- package/docs/app-server.md +86 -0
- package/docs/install.md +1 -1
- package/docs/providers.md +20 -6
- package/package.json +3 -2
- package/src/apps/app-server.ts +28 -0
- package/src/apps/cli.ts +25 -10
- package/src/apps/commands.ts +10 -6
- package/src/apps/completions.ts +15 -13
- package/src/apps/control-transport.ts +89 -0
- package/src/apps/model-catalog.ts +2 -1
- package/src/apps/preferences.ts +20 -0
- package/src/apps/provider-connect.ts +4 -4
- package/src/apps/shell.ts +73 -50
- package/src/apps/startup-input.ts +7 -2
- package/src/apps/startup.ts +52 -19
- package/src/apps/ui/app.ts +210 -56
- package/src/apps/ui/control-model.ts +26 -0
- package/src/apps/ui/surface.ts +2 -0
- package/src/core/completion-action.ts +20 -0
- package/src/core/completion-review.ts +38 -29
- package/src/core/control-protocol.ts +62 -0
- package/src/core/control-view.ts +42 -0
- package/src/core/event-validation.ts +1 -1
- package/src/core/events.ts +2 -0
- package/src/core/harness-diagnostics.ts +206 -0
- package/src/core/model-preferences.ts +28 -0
- package/src/core/oauth-fields.ts +44 -0
- package/src/core/permissions.ts +13 -7
- package/src/core/program-status.ts +31 -0
- package/src/core/project.ts +25 -1
- package/src/core/request-progress.ts +87 -0
- package/src/core/request-state.ts +114 -0
- package/src/core/settings.ts +8 -1
- package/src/core/tool-input.ts +6 -0
- package/src/runtime/auth.ts +10 -3
- package/src/runtime/catalog.ts +21 -4
- package/src/runtime/control-connection.ts +104 -0
- package/src/runtime/harness-eval.ts +3 -1
- package/src/runtime/loop.ts +72 -19
- package/src/runtime/model.ts +9 -0
- package/src/runtime/native-eval.ts +6 -3
- package/src/runtime/oauth.ts +80 -3
- package/src/runtime/pi-provider.ts +169 -12
- package/src/runtime/program-status.ts +38 -0
- package/src/runtime/provider-connect.ts +2 -2
- package/src/runtime/provider-presets.ts +1 -1
- package/src/runtime/provider-request.ts +13 -2
- package/src/runtime/provider.ts +23 -9
- package/src/runtime/session-control.ts +198 -0
- package/src/runtime/tools.ts +11 -9
- package/src/store/resources.ts +13 -22
- package/src/store/user-settings.ts +30 -0
- package/src/tui/ui/composer.ts +3 -2
- package/src/tui/ui/control-panel.ts +25 -0
- package/src/tui/ui/control-sidebar.ts +49 -0
- package/src/tui/ui/mascot.ts +1 -1
- package/src/tui/ui/screen.ts +107 -11
- package/src/tui/ui/status.ts +8 -0
package/src/runtime/tools.ts
CHANGED
|
@@ -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
|
|
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.
|
|
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: "
|
|
394
|
-
expected_sha256: { type: "string", description: "
|
|
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
|
|
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.
|
|
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
|
-
"
|
|
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: "
|
|
423
|
-
read_handle: { type: "string", description: "
|
|
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
|
},
|
package/src/store/resources.ts
CHANGED
|
@@ -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.
|
|
6
|
-
*
|
|
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
|
|
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
|
|
94
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
+
}
|
package/src/tui/ui/composer.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/tui/ui/mascot.ts
CHANGED
|
@@ -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 · /
|
|
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
|
}
|
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.
|
package/src/tui/ui/status.ts
CHANGED
|
@@ -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));
|