@visual-z/xeno 0.2.1 → 0.3.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 +9 -0
- package/README.md +106 -26
- package/docs/install.md +51 -13
- package/docs/providers.md +11 -0
- package/package.json +5 -4
- package/src/apps/approve.ts +3 -3
- package/src/apps/commands.ts +38 -5
- package/src/apps/completions.ts +73 -19
- package/src/apps/image-draft.ts +46 -0
- package/src/apps/project-trust.ts +2 -42
- package/src/apps/provider-connect.ts +346 -0
- package/src/apps/render.ts +22 -47
- package/src/apps/resources.ts +5 -2
- package/src/apps/shell.ts +298 -1526
- package/src/apps/startup-input.ts +45 -9
- package/src/apps/startup.ts +128 -86
- package/src/apps/ui/app.ts +1383 -0
- package/src/apps/ui/extension-host.ts +194 -0
- package/src/apps/ui/image-cache.ts +78 -0
- package/src/apps/ui/palette.ts +88 -0
- package/src/apps/ui/project.ts +127 -0
- package/src/apps/ui/standalone.ts +146 -0
- package/src/apps/ui/status-model.ts +68 -0
- package/src/apps/ui/surface.ts +84 -0
- package/src/apps/ui/theme.ts +86 -0
- package/src/core/acceptance.ts +107 -0
- package/src/core/action-defaults.ts +47 -295
- package/src/core/benchmark.ts +2 -2
- package/src/core/branch-receipt.ts +24 -0
- package/src/core/checkpoints.ts +64 -0
- package/src/core/churn.ts +2 -0
- package/src/core/command-artifacts.ts +94 -0
- package/src/core/command-contract.ts +35 -0
- package/src/core/completion-review.ts +21 -5
- package/src/core/context-detail.ts +37 -0
- package/src/core/context-policy.ts +74 -1
- package/src/core/continuation.ts +17 -2
- package/src/core/derive.ts +29 -3
- package/src/core/draft-images.ts +56 -0
- package/src/core/environment.ts +145 -0
- package/src/core/evaluation-coverage.ts +74 -0
- package/src/core/event-validation.ts +80 -5
- package/src/core/events.ts +97 -3
- package/src/core/evidence-links.ts +7 -0
- package/src/core/evidence.ts +13 -3
- package/src/core/execution.ts +8 -2
- package/src/core/experiment.ts +133 -0
- package/src/core/export.ts +9 -0
- package/src/core/failure-recovery.ts +173 -0
- package/src/core/file-precondition.ts +72 -0
- package/src/core/handoff.ts +65 -4
- package/src/core/history-query.ts +38 -2
- package/src/core/history.ts +38 -1
- package/src/core/integration-health.ts +34 -0
- package/src/core/keymap.ts +8 -20
- package/src/core/model-picker.ts +64 -0
- package/src/core/native-eval.ts +25 -0
- package/src/core/note-guidance.ts +34 -0
- package/src/core/observation-view.ts +49 -8
- package/src/core/permissions.ts +24 -2
- package/src/core/process-receipt.ts +105 -0
- package/src/core/process-state.ts +37 -0
- package/src/core/project.ts +45 -18
- package/src/core/prompt-policy.ts +37 -0
- package/src/core/query-eval-contract.ts +14 -0
- package/src/core/read-observation.ts +82 -0
- package/src/core/recovery-corpus.ts +17 -0
- package/src/core/recovery-evaluation.ts +60 -0
- package/src/core/recovery-sources.ts +74 -0
- package/src/core/runtime-target.ts +94 -0
- package/src/core/search-observation.ts +97 -0
- package/src/core/session-tree.ts +147 -0
- package/src/core/settings.ts +26 -10
- package/src/core/skills.ts +44 -0
- package/src/core/sql-query.ts +211 -0
- package/src/core/theme.ts +36 -58
- package/src/core/tool-contract.ts +51 -2
- package/src/core/tool-discovery.ts +87 -0
- package/src/core/tool-failure.ts +60 -0
- package/src/core/tool-mode-protocol.ts +25 -0
- package/src/core/transcript.ts +19 -4
- package/src/core/trial-lifecycle.ts +14 -0
- package/src/core/tui-mode.ts +29 -0
- package/src/core/update-check.ts +97 -0
- package/src/core/version.ts +55 -0
- package/src/core/work-state.ts +49 -3
- package/src/core/working-set.ts +91 -0
- package/src/runtime/auth.ts +2 -2
- package/src/runtime/branch-jobs.ts +36 -0
- package/src/runtime/clipboard.ts +15 -4
- package/src/runtime/command-artifacts.ts +64 -0
- package/src/runtime/environment-tools.ts +60 -0
- package/src/runtime/evidence-notes.ts +4 -2
- package/src/runtime/exec-sessions.ts +206 -61
- package/src/runtime/execution-control.ts +1 -0
- package/src/runtime/extension-ui.ts +22 -30
- package/src/runtime/extensions.ts +95 -16
- package/src/runtime/harness-comparison.ts +27 -7
- package/src/runtime/harness-eval.ts +460 -0
- package/src/runtime/history-cache.ts +57 -0
- package/src/runtime/history-tools.ts +62 -12
- package/src/runtime/integrations.ts +210 -0
- package/src/runtime/loop.ts +1021 -268
- package/src/runtime/model-discovery.ts +208 -0
- package/src/runtime/native-eval-fixtures.ts +33 -0
- package/src/runtime/native-eval.ts +143 -0
- package/src/runtime/oauth.ts +1 -1
- package/src/runtime/provider-connect.ts +109 -0
- package/src/runtime/provider-presets.ts +87 -0
- package/src/runtime/query-eval-verifier.ts +28 -0
- package/src/runtime/recovery-coverage.ts +71 -0
- package/src/runtime/recovery-evidence.ts +76 -0
- package/src/runtime/recovery-fixtures.ts +116 -0
- package/src/runtime/runtime-target.ts +149 -0
- package/src/runtime/tool-broker.ts +137 -0
- package/src/runtime/tool-discovery.ts +57 -0
- package/src/runtime/tool-mode-eval.ts +146 -0
- package/src/runtime/tool-queue.ts +77 -0
- package/src/runtime/tools.ts +628 -183
- package/src/runtime/trial-control.ts +45 -0
- package/src/runtime/update-check.ts +47 -0
- package/src/runtime/window-trial.ts +2 -1
- package/src/runtime/working-notes.ts +33 -3
- package/src/runtime/workspace-lifecycle.ts +97 -0
- package/src/store/acceptance.ts +15 -0
- package/src/store/checkpoint-history.ts +43 -0
- package/src/store/command-artifacts.ts +96 -0
- package/src/store/command.ts +65 -37
- package/src/store/file-checkpoints.ts +104 -0
- package/src/store/file-index.ts +13 -1
- package/src/store/git-checkpoints.ts +67 -0
- package/src/store/observations.ts +61 -20
- package/src/store/process-receipts.ts +34 -0
- package/src/store/query-source.ts +13 -8
- package/src/store/recovery.ts +2 -3
- package/src/store/replay.ts +16 -4
- package/src/store/runtime-target.ts +64 -0
- package/src/store/search.ts +171 -132
- package/src/store/session-archive.ts +4 -0
- package/src/store/source-cancellation.ts +6 -0
- package/src/store/update-cache.ts +36 -0
- package/src/store/workspace-observation.ts +113 -0
- package/src/tui/ansi-spans.ts +126 -0
- package/src/tui/style.ts +19 -16
- package/src/tui/ui/atomic-draft.ts +121 -0
- package/src/tui/ui/canvas.ts +96 -0
- package/src/tui/ui/colors.ts +70 -0
- package/src/tui/ui/composer.ts +199 -0
- package/src/tui/ui/dialog.ts +379 -0
- package/src/tui/ui/draft.ts +23 -0
- package/src/tui/ui/edge.ts +186 -0
- package/src/tui/ui/image-size.ts +40 -0
- package/src/tui/ui/ink.ts +210 -0
- package/src/tui/ui/items.ts +40 -0
- package/src/tui/ui/keys.ts +40 -0
- package/src/tui/ui/layout.ts +74 -0
- package/src/tui/ui/list-model.ts +86 -0
- package/src/tui/ui/list-view.ts +172 -0
- package/src/tui/ui/mascot.ts +67 -0
- package/src/tui/ui/messages.ts +518 -0
- package/src/tui/ui/motion.ts +28 -0
- package/src/tui/ui/paths.ts +10 -0
- package/src/tui/ui/pictures.ts +78 -0
- package/src/tui/ui/screen.ts +421 -0
- package/src/tui/ui/status.ts +271 -0
- package/src/tui/ui/styled.ts +85 -0
- package/src/tui/ui/testing.ts +5 -0
- package/src/tui/ui/tool-card.ts +150 -0
- package/src/apps/extension-ui.ts +0 -330
- package/src/apps/resource-browser.ts +0 -75
- package/src/apps/session-browser.ts +0 -113
- package/src/apps/session-picker.ts +0 -55
- package/src/tui/ansi.ts +0 -67
- package/src/tui/autocomplete.ts +0 -29
- package/src/tui/chat-viewport.ts +0 -47
- package/src/tui/complete.ts +0 -87
- package/src/tui/editor.ts +0 -940
- package/src/tui/host.ts +0 -264
- package/src/tui/input.ts +0 -75
- package/src/tui/keys.ts +0 -378
- package/src/tui/markdown.ts +0 -17
- package/src/tui/render.ts +0 -153
- package/src/tui/select.ts +0 -258
- package/src/tui/settings.ts +0 -44
- package/src/tui/statusline.ts +0 -162
- package/src/tui/system-theme.ts +0 -700
- package/src/tui/terminal.ts +0 -30
- package/src/tui/text.ts +0 -52
- package/src/tui/toolview.ts +0 -386
- package/src/tui/transcript.ts +0 -123
- package/src/tui/tree.ts +0 -471
- package/src/vendor/pi-tui/LICENSE.upstream +0 -21
- package/src/vendor/pi-tui/UPSTREAM.json +0 -50
- package/src/vendor/pi-tui/VENDOR.md +0 -25
- package/src/vendor/pi-tui/alt-screen-search.ts +0 -327
- package/src/vendor/pi-tui/autocomplete.ts +0 -860
- package/src/vendor/pi-tui/colors.ts +0 -367
- package/src/vendor/pi-tui/components/alt-screen-flash.ts +0 -51
- package/src/vendor/pi-tui/components/box.ts +0 -166
- package/src/vendor/pi-tui/components/cancellable-loader.ts +0 -40
- package/src/vendor/pi-tui/components/editor.ts +0 -2472
- package/src/vendor/pi-tui/components/h-stack.ts +0 -44
- package/src/vendor/pi-tui/components/image.ts +0 -127
- package/src/vendor/pi-tui/components/input.ts +0 -494
- package/src/vendor/pi-tui/components/loader.ts +0 -101
- package/src/vendor/pi-tui/components/markdown.ts +0 -1021
- package/src/vendor/pi-tui/components/mouse-region.ts +0 -33
- package/src/vendor/pi-tui/components/scroll-view.ts +0 -224
- package/src/vendor/pi-tui/components/select-list.ts +0 -273
- package/src/vendor/pi-tui/components/settings-list.ts +0 -328
- package/src/vendor/pi-tui/components/spacer.ts +0 -28
- package/src/vendor/pi-tui/components/stack.ts +0 -154
- package/src/vendor/pi-tui/components/text.ts +0 -107
- package/src/vendor/pi-tui/components/truncated-text.ts +0 -65
- package/src/vendor/pi-tui/components/v-stack.ts +0 -33
- package/src/vendor/pi-tui/editor-component.ts +0 -74
- package/src/vendor/pi-tui/fuzzy.ts +0 -138
- package/src/vendor/pi-tui/index.ts +0 -186
- package/src/vendor/pi-tui/keybindings.ts +0 -320
- package/src/vendor/pi-tui/keys.ts +0 -1401
- package/src/vendor/pi-tui/kill-ring.ts +0 -46
- package/src/vendor/pi-tui/latex.ts +0 -1506
- package/src/vendor/pi-tui/layout-node.ts +0 -51
- package/src/vendor/pi-tui/layout.ts +0 -449
- package/src/vendor/pi-tui/native/UPSTREAM.json +0 -23
- package/src/vendor/pi-tui/native/clipboard.h +0 -208
- package/src/vendor/pi-tui/native/darwin/README.md +0 -22
- package/src/vendor/pi-tui/native/darwin/build.sh +0 -69
- package/src/vendor/pi-tui/native/darwin/prebuilds/darwin-arm64/darwin-platform.node +0 -0
- package/src/vendor/pi-tui/native/darwin/prebuilds/darwin-x64/darwin-platform.node +0 -0
- package/src/vendor/pi-tui/native/darwin/src/darwin-platform.m +0 -132
- package/src/vendor/pi-tui/native/linux/README.md +0 -23
- package/src/vendor/pi-tui/native/linux/build.sh +0 -65
- package/src/vendor/pi-tui/native/linux/prebuilds/linux-arm64/linux-platform-x11.node +0 -0
- package/src/vendor/pi-tui/native/linux/prebuilds/linux-x64/linux-platform-x11.node +0 -0
- package/src/vendor/pi-tui/native/linux/src/clipboard-worker.h +0 -70
- package/src/vendor/pi-tui/native/linux/src/linux-platform-x11.c +0 -274
- package/src/vendor/pi-tui/native/napi.h +0 -91
- package/src/vendor/pi-tui/native/win32/README.md +0 -31
- package/src/vendor/pi-tui/native/win32/build.mjs +0 -232
- package/src/vendor/pi-tui/native/win32/prebuilds/win32-arm64/win32-platform.node +0 -0
- package/src/vendor/pi-tui/native/win32/prebuilds/win32-x64/win32-platform.node +0 -0
- package/src/vendor/pi-tui/native/win32/src/win32-platform.c +0 -241
- package/src/vendor/pi-tui/native-modifiers.ts +0 -13
- package/src/vendor/pi-tui/native-module-path.ts +0 -31
- package/src/vendor/pi-tui/native-platform.ts +0 -65
- package/src/vendor/pi-tui/oklab.ts +0 -233
- package/src/vendor/pi-tui/stdin-buffer.ts +0 -444
- package/src/vendor/pi-tui/terminal-colors.ts +0 -91
- package/src/vendor/pi-tui/terminal-image.ts +0 -731
- package/src/vendor/pi-tui/terminal.ts +0 -554
- package/src/vendor/pi-tui/tui-alt-screen.ts +0 -1750
- package/src/vendor/pi-tui/tui-main-screen.ts +0 -655
- package/src/vendor/pi-tui/tui.ts +0 -1493
- package/src/vendor/pi-tui/undo-stack.ts +0 -28
- package/src/vendor/pi-tui/utils.ts +0 -1385
- package/src/vendor/pi-tui/wheel-scroll.ts +0 -82
- package/src/vendor/pi-tui/word-navigation.ts +0 -117
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ExtensionUI for the interactive view. Dialog requests are serialized (one
|
|
3
|
+
* extension question at a time, in arrival order) and all cancel on
|
|
4
|
+
* shutdown or session switch, resolving as "dismissed". A call arriving from
|
|
5
|
+
* a disposed generation (an extension of the previous session finishing
|
|
6
|
+
* late) is ignored rather than mutating the new session's UI.
|
|
7
|
+
*/
|
|
8
|
+
import type { Theme } from "../../core/theme.ts";
|
|
9
|
+
import type { DialogOptions, ExtensionComponent, ExtensionTheme, ExtensionUI } from "../../runtime/extension-ui.ts";
|
|
10
|
+
import type { ExtensionRegistry, ExtensionToolResult } from "../../runtime/extensions.ts";
|
|
11
|
+
import type { DialogSpec } from "../../tui/ui/dialog.ts";
|
|
12
|
+
|
|
13
|
+
export interface ExtensionHostDeps {
|
|
14
|
+
dialog(spec: DialogSpec): void;
|
|
15
|
+
closeDialog(spec: DialogSpec): void;
|
|
16
|
+
notify(message: string, tone: "info" | "warning" | "error"): void;
|
|
17
|
+
redraw(): void;
|
|
18
|
+
theme(): Theme;
|
|
19
|
+
colored(): boolean;
|
|
20
|
+
editorText(): string;
|
|
21
|
+
setEditorText(text: string): void;
|
|
22
|
+
insertEditorText(text: string): void;
|
|
23
|
+
toolsExpanded(): boolean;
|
|
24
|
+
setToolsExpanded(value: boolean): void;
|
|
25
|
+
setTitle(title: string): void;
|
|
26
|
+
cwd(): string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class ExtensionHost {
|
|
30
|
+
private readonly deps: ExtensionHostDeps;
|
|
31
|
+
private generation = 0;
|
|
32
|
+
private queue: Promise<unknown> = Promise.resolve();
|
|
33
|
+
private readonly pending = new Set<() => void>();
|
|
34
|
+
private readonly statuses = new Map<string, string>();
|
|
35
|
+
private readonly widgets = new Map<string, string[]>();
|
|
36
|
+
private readonly toolState = new Map<string, Record<string, unknown>>();
|
|
37
|
+
private readonly partials = new Map<string, ExtensionToolResult>();
|
|
38
|
+
private registry: ExtensionRegistry | undefined;
|
|
39
|
+
private unwatch: (() => void) | undefined;
|
|
40
|
+
workingMessage: string | undefined;
|
|
41
|
+
hiddenThinkingLabel: string | undefined;
|
|
42
|
+
private api: ExtensionUI;
|
|
43
|
+
|
|
44
|
+
constructor(deps: ExtensionHostDeps) {
|
|
45
|
+
this.deps = deps;
|
|
46
|
+
this.api = this.createApi();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
get context(): ExtensionUI { return this.api; }
|
|
50
|
+
get status(): string { return [...this.statuses.values()].join(" · "); }
|
|
51
|
+
get widgetLines(): string[] { return [...this.widgets.values()].flat(); }
|
|
52
|
+
|
|
53
|
+
bind(registry: ExtensionRegistry | undefined): void {
|
|
54
|
+
if (registry === this.registry && this.unwatch) return;
|
|
55
|
+
if (registry !== this.registry) this.reset();
|
|
56
|
+
this.registry = registry;
|
|
57
|
+
this.unwatch = registry?.onToolUpdate((_name, id, _input, result) => { this.partials.set(id, result); this.deps.redraw(); });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** New generation: cancel dialogs, drop widgets and statuses. Late calls from the old one become no-ops. */
|
|
61
|
+
reset(): void {
|
|
62
|
+
this.generation++;
|
|
63
|
+
this.cancelDialogs();
|
|
64
|
+
this.unwatch?.(); this.unwatch = undefined; this.registry = undefined;
|
|
65
|
+
this.statuses.clear(); this.widgets.clear(); this.toolState.clear(); this.partials.clear();
|
|
66
|
+
this.workingMessage = undefined; this.hiddenThinkingLabel = undefined;
|
|
67
|
+
this.api = this.createApi();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
cancelDialogs(): void { for (const cancel of [...this.pending]) cancel(); }
|
|
71
|
+
|
|
72
|
+
partial(id: string): ExtensionToolResult | undefined { return this.partials.get(id); }
|
|
73
|
+
clearPartials(): void { this.partials.clear(); }
|
|
74
|
+
|
|
75
|
+
theme(): ExtensionTheme {
|
|
76
|
+
const theme = this.deps.theme();
|
|
77
|
+
const colored = this.deps.colored();
|
|
78
|
+
const sgr = (color: string) => colored && theme.colors[color] ? `\x1b[${theme.colors[color]}m` : "";
|
|
79
|
+
const wrap = (open: string, text: string, close: string) => open ? open + text + close : text;
|
|
80
|
+
const attr = (on: number, off: number) => (text: string) => wrap(colored ? `\x1b[${on}m` : "", text, `\x1b[${off}m`);
|
|
81
|
+
return { ...theme, fg: (color, text) => wrap(sgr(color), text, "\x1b[39m"), bg: (color, text) => wrap(sgr(color), text, "\x1b[49m"),
|
|
82
|
+
bold: attr(1, 22), dim: attr(2, 22), italic: attr(3, 23), underline: attr(4, 24), inverse: attr(7, 27), strikethrough: attr(9, 29) };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Render an extension component safely: a throwing renderer shows its error, not a crash. */
|
|
86
|
+
lines(component: ExtensionComponent | undefined, width: number): string[] {
|
|
87
|
+
if (!component) return [];
|
|
88
|
+
try { return component.render(width); } catch (error) { this.deps.notify(`Extension renderer: ${String(error)}`, "error"); return [`(extension renderer failed: ${String(error)})`]; }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
toolRenderer(name: string, input: unknown, result: { text: string; ok: boolean; data?: unknown } | null, id: string): ((width: number) => string[]) | undefined {
|
|
92
|
+
const tool = this.registry?.tools.get(name);
|
|
93
|
+
if (!tool?.renderCall && !tool?.renderResult) return undefined;
|
|
94
|
+
const state = this.toolState.get(id) ?? {};
|
|
95
|
+
this.toolState.set(id, state);
|
|
96
|
+
const partial = this.partials.get(id);
|
|
97
|
+
const expanded = this.deps.toolsExpanded();
|
|
98
|
+
return width => {
|
|
99
|
+
const context = { args: input, toolCallId: id, invalidate: () => this.deps.redraw(), lastComponent: undefined, state, cwd: this.deps.cwd(),
|
|
100
|
+
executionStarted: result !== null || partial !== undefined, argsComplete: true, isPartial: result === null, expanded, showImages: false, isError: result?.ok === false };
|
|
101
|
+
const call = tool.renderCall ? this.lines(tool.renderCall(input, this.theme(), context), width) : [];
|
|
102
|
+
const payload: ExtensionToolResult | undefined = result
|
|
103
|
+
? resultPayload(result)
|
|
104
|
+
: partial;
|
|
105
|
+
const body = payload && tool.renderResult ? this.lines(tool.renderResult(payload, { expanded, isPartial: result === null }, this.theme(), context), width) : [];
|
|
106
|
+
return [...call, ...body];
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Extension Markdown transformers, in registration order; a throwing one is skipped. Display only. */
|
|
111
|
+
transformMarkdown(text: string, context: { streaming: boolean; messageType: "assistant" | "assistant-thinking"; width: number }): string {
|
|
112
|
+
for (const transform of this.registry?.markdown ?? []) {
|
|
113
|
+
// Context keys are the ones extensions already read (availableWidth / isStreaming / messageType).
|
|
114
|
+
try { text = transform(text, { availableWidth: context.width, isStreaming: context.streaming, messageType: context.messageType }) ?? text; } catch (error) { this.deps.notify(`Markdown transformer: ${String(error)}`, "error"); }
|
|
115
|
+
}
|
|
116
|
+
return text;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
messageRenderer(type: string, text: string, data: unknown, entry: boolean): ((width: number) => string[]) | undefined {
|
|
120
|
+
const renderer = (entry ? this.registry?.entryRenderers.get(type) : undefined) ?? this.registry?.renderers.get(type);
|
|
121
|
+
if (!renderer) return undefined;
|
|
122
|
+
return width => {
|
|
123
|
+
try {
|
|
124
|
+
const component = renderer({ customType: type, content: text, ...(data === undefined ? {} : { details: data }) }, { expanded: this.deps.toolsExpanded(), outputPad: 1 }, this.theme());
|
|
125
|
+
return component ? this.lines(component, width) : [text];
|
|
126
|
+
} catch (error) { return [`(extension renderer failed: ${String(error)})`, text]; }
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
private createApi(): ExtensionUI {
|
|
131
|
+
const generation = this.generation;
|
|
132
|
+
const live = () => generation === this.generation;
|
|
133
|
+
const api: ExtensionUI = {
|
|
134
|
+
select: (title, options, opts) => this.ask<string | undefined>(undefined, opts, resolve => ({
|
|
135
|
+
kind: "list", title, items: options.map(option => ({ value: option, label: option })), onPick: item => resolve(item.value), onCancel: () => resolve(undefined),
|
|
136
|
+
})),
|
|
137
|
+
confirm: (title, message, opts) => this.ask<boolean>(false, opts, resolve => ({ kind: "confirm", title, message, onAnswer: yes => resolve(yes) })),
|
|
138
|
+
input: (title, placeholder, opts) => this.ask<string | undefined>(undefined, opts, resolve => ({
|
|
139
|
+
kind: "form", title, label: placeholder || "value", onSubmit: value => resolve(value), onCancel: () => resolve(undefined),
|
|
140
|
+
})),
|
|
141
|
+
show: (title, component) => this.ask<void>(undefined, undefined, resolve => ({
|
|
142
|
+
kind: "lines", title, render: width => this.lines(component, width), onCancel: () => { component.dispose?.(); resolve(undefined); },
|
|
143
|
+
})),
|
|
144
|
+
notify: (message, type) => { if (live()) this.deps.notify(message, type ?? "info"); },
|
|
145
|
+
setStatus: (key, text) => { if (!live()) return; if (text === undefined) this.statuses.delete(key); else this.statuses.set(key, text); this.deps.redraw(); },
|
|
146
|
+
setWidget: (key, lines) => { if (!live()) return; if (lines === undefined) this.widgets.delete(key); else this.widgets.set(key, lines); this.deps.redraw(); },
|
|
147
|
+
setWorkingMessage: message => { if (!live()) return; this.workingMessage = message; this.deps.redraw(); },
|
|
148
|
+
setHiddenThinkingLabel: label => { if (!live()) return; this.hiddenThinkingLabel = label; this.deps.redraw(); },
|
|
149
|
+
setTitle: title => { if (live()) this.deps.setTitle(title); },
|
|
150
|
+
getEditorText: () => this.deps.editorText(),
|
|
151
|
+
setEditorText: text => { if (live()) this.deps.setEditorText(text); },
|
|
152
|
+
pasteToEditor: text => { if (live()) this.deps.insertEditorText(text); },
|
|
153
|
+
get theme() { return hostTheme(); },
|
|
154
|
+
getToolsExpanded: () => this.deps.toolsExpanded(),
|
|
155
|
+
setToolsExpanded: value => { if (live()) this.deps.setToolsExpanded(value); },
|
|
156
|
+
};
|
|
157
|
+
const hostTheme = () => this.theme();
|
|
158
|
+
return api;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** One extension dialog at a time; signal/timeout/shutdown resolve with `dismissed`. */
|
|
162
|
+
private ask<T>(dismissed: T, opts: DialogOptions | undefined, spec: (resolve: (value: T) => void) => DialogSpec): Promise<T> {
|
|
163
|
+
const generation = this.generation;
|
|
164
|
+
const run = () => new Promise<T>(resolve => {
|
|
165
|
+
if (generation !== this.generation || opts?.signal?.aborted) { resolve(dismissed); return; }
|
|
166
|
+
let done = false, timer: ReturnType<typeof setTimeout> | undefined, shown: DialogSpec | undefined;
|
|
167
|
+
const finish = (value: T, close: boolean) => {
|
|
168
|
+
if (done) return;
|
|
169
|
+
done = true;
|
|
170
|
+
clearTimeout(timer);
|
|
171
|
+
opts?.signal?.removeEventListener("abort", abort);
|
|
172
|
+
this.pending.delete(cancel);
|
|
173
|
+
if (close && shown) this.deps.closeDialog(shown);
|
|
174
|
+
resolve(value);
|
|
175
|
+
};
|
|
176
|
+
const cancel = () => finish(dismissed, true);
|
|
177
|
+
const abort = () => finish(dismissed, true);
|
|
178
|
+
this.pending.add(cancel);
|
|
179
|
+
opts?.signal?.addEventListener("abort", abort, { once: true });
|
|
180
|
+
if (opts?.timeout) timer = setTimeout(abort, opts.timeout);
|
|
181
|
+
shown = spec(value => finish(value, false));
|
|
182
|
+
this.deps.dialog(shown);
|
|
183
|
+
});
|
|
184
|
+
const next = this.queue.then(run, run);
|
|
185
|
+
this.queue = next.catch(() => undefined);
|
|
186
|
+
return next;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function resultPayload(result: { text: string; ok: boolean; data?: unknown }): ExtensionToolResult {
|
|
191
|
+
const payload: ExtensionToolResult = { content: [{ type: "text", text: result.text }], isError: !result.ok };
|
|
192
|
+
if (result.data !== undefined) payload.details = result.data as NonNullable<ExtensionToolResult["details"]>;
|
|
193
|
+
return payload;
|
|
194
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decoded pictures, shared and bounded (docs/tasks/feat-tui-inline-images.md DoD 4).
|
|
3
|
+
*
|
|
4
|
+
* The log stores each image once as a content-addressed blob; this keeps one
|
|
5
|
+
* decoded NativeImage per blob for every node that shows it. Each picture
|
|
6
|
+
* node retains its own reference (OpenTUI counts them), so evicting an entry
|
|
7
|
+
* here only drops the cache's reference: a picture still on screen keeps
|
|
8
|
+
* its pixels, and memory is returned once the last node goes.
|
|
9
|
+
*
|
|
10
|
+
* Bytes come from the blob store, which checks the hash; a missing or
|
|
11
|
+
* corrupt blob, or one that will not decode, resolves to null and the view
|
|
12
|
+
* names the picture instead of drawing it.
|
|
13
|
+
*/
|
|
14
|
+
import { NativeImage } from "@opentui/core";
|
|
15
|
+
import { getBlobBytes } from "../../store/blobs.ts";
|
|
16
|
+
|
|
17
|
+
/** Decoded RGBA kept for reuse. 64 MiB is in line with Grok's and pi's image budgets; assumed, not measured here. */
|
|
18
|
+
export const IMAGE_CACHE_BYTES = 64 * 1024 * 1024;
|
|
19
|
+
|
|
20
|
+
interface Entry { readonly image: Promise<NativeImage | null>; bytes: number }
|
|
21
|
+
|
|
22
|
+
export class ImageCache {
|
|
23
|
+
private readonly entries = new Map<string, Entry>();
|
|
24
|
+
private used = 0;
|
|
25
|
+
private readonly blobDir: () => string;
|
|
26
|
+
private readonly budget: number;
|
|
27
|
+
|
|
28
|
+
constructor(blobDir: () => string, budget = IMAGE_CACHE_BYTES) {
|
|
29
|
+
this.blobDir = blobDir;
|
|
30
|
+
this.budget = budget;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Bytes of decoded pixels held now (tests). */
|
|
34
|
+
get size(): number { return this.used; }
|
|
35
|
+
get count(): number { return this.entries.size; }
|
|
36
|
+
|
|
37
|
+
load(sha256: string): Promise<NativeImage | null> {
|
|
38
|
+
const hit = this.entries.get(sha256);
|
|
39
|
+
if (hit) {
|
|
40
|
+
// Most recently used goes last; eviction starts from the front.
|
|
41
|
+
this.entries.delete(sha256); this.entries.set(sha256, hit);
|
|
42
|
+
return hit.image;
|
|
43
|
+
}
|
|
44
|
+
const entry: Entry = { image: this.decode(sha256), bytes: 0 };
|
|
45
|
+
this.entries.set(sha256, entry);
|
|
46
|
+
void entry.image.then(native => {
|
|
47
|
+
if (this.entries.get(sha256) !== entry) return;
|
|
48
|
+
entry.bytes = native ? native.width * native.height * 4 : 0;
|
|
49
|
+
this.used += entry.bytes;
|
|
50
|
+
this.evict(sha256);
|
|
51
|
+
});
|
|
52
|
+
return entry.image;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
private async decode(sha256: string): Promise<NativeImage | null> {
|
|
56
|
+
try {
|
|
57
|
+
const bytes = await getBlobBytes(this.blobDir(), sha256);
|
|
58
|
+
return bytes ? await NativeImage.load(bytes) : null;
|
|
59
|
+
} catch { return null; }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Drop least recently used entries until the budget holds, never the one just loaded. */
|
|
63
|
+
private evict(keep: string): void {
|
|
64
|
+
for (const [sha, entry] of this.entries) {
|
|
65
|
+
if (this.used <= this.budget) break;
|
|
66
|
+
if (sha === keep) continue;
|
|
67
|
+
this.entries.delete(sha);
|
|
68
|
+
this.used -= entry.bytes;
|
|
69
|
+
void entry.image.then(native => native?.dispose());
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
dispose(): void {
|
|
74
|
+
for (const entry of this.entries.values()) void entry.image.then(native => native?.dispose());
|
|
75
|
+
this.entries.clear();
|
|
76
|
+
this.used = 0;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ctrl+P: every command and action in one searchable list.
|
|
3
|
+
*
|
|
4
|
+
* Sources: the slash-command catalog (COMMANDS, prompt templates, skills,
|
|
5
|
+
* extension commands) plus actions that have no slash spelling (toggle tools,
|
|
6
|
+
* scroll, sidebar) and the ready models. The `/` completion menu reads the
|
|
7
|
+
* same command entries, so a command is discoverable exactly one way
|
|
8
|
+
* (OpenCode's command registry). Pure: the controller supplies state.
|
|
9
|
+
*/
|
|
10
|
+
import type { ListItem } from "../../tui/ui/list-model.ts";
|
|
11
|
+
import { COMMANDS } from "../commands.ts";
|
|
12
|
+
|
|
13
|
+
export interface PaletteAction { readonly id: string; readonly label: string; readonly description: string; readonly section: string; readonly chord?: string; readonly when?: boolean }
|
|
14
|
+
|
|
15
|
+
export interface PaletteInput {
|
|
16
|
+
readonly bindings: Readonly<Record<string, readonly string[]>>;
|
|
17
|
+
readonly busy: boolean;
|
|
18
|
+
readonly extraCommands: readonly { name: string; description: string; section: string }[];
|
|
19
|
+
readonly models: readonly { key: string; label: string; description: string; current: boolean }[];
|
|
20
|
+
readonly toolsExpanded: boolean;
|
|
21
|
+
readonly thinkingExpanded: boolean;
|
|
22
|
+
readonly sidebar: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Which section a built-in command belongs to; unlisted ones go under "Commands". */
|
|
26
|
+
const SECTION: Readonly<Record<string, string>> = {
|
|
27
|
+
model: "Model", "scoped-models": "Model", thinking: "Model", login: "Model", logout: "Model",
|
|
28
|
+
new: "Session", resume: "Session", tree: "Session", fork: "Session", clone: "Session", back: "Session", name: "Session", session: "Session",
|
|
29
|
+
export: "Session", import: "Session", copy: "Session",
|
|
30
|
+
usage: "Inspect", state: "Inspect", context: "Inspect", acceptance: "Inspect", tasks: "Inspect", branches: "Inspect", processes: "Inspect", debug: "Inspect",
|
|
31
|
+
settings: "Configure", reload: "Configure", config: "Configure", trust: "Configure", hotkeys: "Configure", models: "Configure",
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
function chord(bindings: PaletteInput["bindings"], action: string): string | undefined {
|
|
35
|
+
return bindings[action]?.[0];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function paletteActions(input: PaletteInput): PaletteAction[] {
|
|
39
|
+
const b = input.bindings;
|
|
40
|
+
const actions: PaletteAction[] = [
|
|
41
|
+
{ id: "action:toggleTools", label: input.toolsExpanded ? "Collapse tool output" : "Expand tool output", description: "all tool cards", section: "View", ...opt(chord(b, "app.toggleTools")) },
|
|
42
|
+
{ id: "action:toggleThinking", label: input.thinkingExpanded ? "Hide thinking" : "Show thinking", description: "reasoning blocks", section: "View", ...opt(chord(b, "app.toggleThinking")) },
|
|
43
|
+
{ id: "action:toggleSidebar", label: input.sidebar ? "Hide sidebar" : "Show sidebar", description: "segment state (≥120 columns)", section: "View", ...opt(chord(b, "app.toggleSidebar")) },
|
|
44
|
+
{ id: "action:appearance", label: "Appearance", description: "dark, light or follow terminal", section: "View" },
|
|
45
|
+
{ id: "action:animations", label: "Animations", description: "enable or reduce motion", section: "View" },
|
|
46
|
+
{ id: "action:bottom", label: "Jump to latest", description: "resume following new output", section: "View", ...opt(chord(b, "app.scroll.bottom")) },
|
|
47
|
+
{ id: "action:top", label: "Jump to start", description: "first message of this session", section: "View", ...opt(chord(b, "app.scroll.top")) },
|
|
48
|
+
{ id: "action:editor", label: "Open draft in $EDITOR", description: "edit the prompt externally", section: "Compose", ...opt(chord(b, "app.externalEditor")) },
|
|
49
|
+
{ id: "action:pasteImage", label: "Paste image from clipboard", description: "attach to the draft", section: "Compose", ...opt(chord(b, "app.clipboard.pasteImage")) },
|
|
50
|
+
{ id: "action:cycleThinking", label: "Cycle thinking level", description: "next supported level", section: "Model", ...opt(chord(b, "app.cycleThinking")) },
|
|
51
|
+
{ id: "action:interrupt", label: "Interrupt", description: "stop the running turn", section: "Run", when: input.busy, ...opt(chord(b, "app.interrupt")) },
|
|
52
|
+
{ id: "action:quit", label: "Quit", description: "exit xeno", section: "Run", ...opt(chord(b, "app.exit")) },
|
|
53
|
+
];
|
|
54
|
+
return actions.filter(action => action.when !== false);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function opt(value: string | undefined): { chord?: string } { return value ? { chord: value } : {}; }
|
|
58
|
+
|
|
59
|
+
export function paletteItems(input: PaletteInput): ListItem[] {
|
|
60
|
+
const items: ListItem[] = [];
|
|
61
|
+
const model = input.models.find(entry => entry.current);
|
|
62
|
+
for (const action of paletteActions(input)) items.push({ value: action.id, label: action.label, description: action.description, section: action.section, ...(action.chord ? { hint: action.chord } : {}) });
|
|
63
|
+
for (const command of COMMANDS) {
|
|
64
|
+
if (command.name === "exit" || command.name === "quit") continue;
|
|
65
|
+
const blocked = input.busy && command.busy === "idle";
|
|
66
|
+
items.push({ value: `command:${command.name}`, label: `/${command.name}`, description: blocked ? `${command.description} · needs an idle session` : command.description,
|
|
67
|
+
section: SECTION[command.name] ?? "Commands", disabled: blocked,
|
|
68
|
+
...(command.name === "model" && model ? { hint: model.label } : command.name === "model" && input.bindings["app.model"]?.[0] ? { hint: input.bindings["app.model"][0] } : {}) });
|
|
69
|
+
}
|
|
70
|
+
for (const command of input.extraCommands) items.push({ value: `command:${command.name}`, label: `/${command.name}`, description: command.description, section: command.section });
|
|
71
|
+
for (const entry of input.models) items.push({ value: `model:${entry.key}`, label: entry.label, description: entry.description, section: "Switch model", keywords: entry.key, disabled: input.busy,
|
|
72
|
+
...(input.busy ? { hint: "after this turn" } : entry.current ? { hint: "current" } : {}) });
|
|
73
|
+
return sortSections(items);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const ORDER = ["View", "Model", "Session", "Compose", "Inspect", "Configure", "Commands", "Prompts", "Skills", "Extensions", "Run", "Switch model"];
|
|
77
|
+
function sortSections(items: ListItem[]): ListItem[] {
|
|
78
|
+
const rank = (section: string | undefined) => { const at = ORDER.indexOf(section ?? ""); return at < 0 ? ORDER.length : at; };
|
|
79
|
+
return items.map((item, index) => ({ item, index })).sort((a, b) => rank(a.item.section) - rank(b.item.section) || a.index - b.index).map(entry => entry.item);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** `/` completion: the same command entries, filtered by prefix, ranked by the list model's fuzzy order. */
|
|
83
|
+
export function slashItems(input: PaletteInput, prefix: string): ListItem[] {
|
|
84
|
+
const all = paletteItems(input).filter(item => item.value.startsWith("command:"));
|
|
85
|
+
const q = prefix.toLowerCase();
|
|
86
|
+
return all.filter(item => item.label.slice(1).toLowerCase().startsWith(q)).concat(all.filter(item => !item.label.slice(1).toLowerCase().startsWith(q) && item.label.toLowerCase().includes(q)))
|
|
87
|
+
.map(item => ({ ...item, value: `${item.label} ` }));
|
|
88
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Log projection → view items. Pure apart from the renderer callbacks it
|
|
3
|
+
* hands to tool cards, which are themselves pure functions of the log.
|
|
4
|
+
*
|
|
5
|
+
* Inputs are the durable transcript (core/transcript.ts), the UI-only
|
|
6
|
+
* notices anchored to it, and the live (uncommitted) stream. The stream is
|
|
7
|
+
* never history: once the log has the assistant event, the stream is gone
|
|
8
|
+
* and the projected item takes its place (same id scheme, no flash).
|
|
9
|
+
*/
|
|
10
|
+
import type { TranscriptItem } from "../../core/transcript.ts";
|
|
11
|
+
import type { ImageView, ViewItem } from "../../tui/ui/items.ts";
|
|
12
|
+
import type { ImageRef } from "../../core/images.ts";
|
|
13
|
+
import { toolCard, toolDetail, toolPreview, toolSummary } from "../../tui/ui/tool-card.ts";
|
|
14
|
+
import type { Style } from "../../tui/style.ts";
|
|
15
|
+
|
|
16
|
+
export interface Notice { readonly id: string; readonly after: number; readonly item: ViewItem }
|
|
17
|
+
|
|
18
|
+
export interface LiveStream {
|
|
19
|
+
readonly thinking: string;
|
|
20
|
+
readonly text: string;
|
|
21
|
+
readonly tools: readonly { id: string; name: string; arguments: string }[];
|
|
22
|
+
readonly output: string;
|
|
23
|
+
readonly bash: string | null;
|
|
24
|
+
readonly retry?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ProjectOptions {
|
|
28
|
+
readonly notices: readonly Notice[];
|
|
29
|
+
readonly live?: LiveStream;
|
|
30
|
+
/** Extension renderers; return lines to replace the default card, undefined to keep it. */
|
|
31
|
+
readonly customTool?: (item: Extract<TranscriptItem, { kind: "tool" }>) => ((width: number) => string[]) | undefined;
|
|
32
|
+
readonly customMessage?: (item: Extract<TranscriptItem, { kind: "custom" }>) => ((width: number) => string[]) | undefined;
|
|
33
|
+
readonly hiddenThinkingLabel?: string;
|
|
34
|
+
readonly style?: () => Style | undefined;
|
|
35
|
+
/**
|
|
36
|
+
* Views of durable items from earlier paints, by item id. A log item never
|
|
37
|
+
* changes without a new revision, so an equal key means an equal view, and
|
|
38
|
+
* a long session re-projects only what changed (refactor-tui-chrome-cleanup
|
|
39
|
+
* DoD 5). The caller clears it when renderers or labels change.
|
|
40
|
+
*/
|
|
41
|
+
readonly cache?: Map<string, { readonly key: string; readonly view: ViewItem | undefined }>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** CutReason → words. Unknown reasons (future log versions) print verbatim. */
|
|
45
|
+
const SEGMENT_REASONS: Readonly<Record<string, string>> = {
|
|
46
|
+
trigger: "context boundary", hard_limit: "context limit", model_checkpoint: "model switch",
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export function projectItems(items: readonly TranscriptItem[], options: ProjectOptions): ViewItem[] {
|
|
50
|
+
const out: ViewItem[] = [];
|
|
51
|
+
let notice = 0;
|
|
52
|
+
const flush = (upTo: number) => { while (notice < options.notices.length && options.notices[notice]!.after <= upTo) out.push(options.notices[notice++]!.item); };
|
|
53
|
+
flush(0);
|
|
54
|
+
let segment = 1;
|
|
55
|
+
items.forEach((item, index) => {
|
|
56
|
+
// A segment's label depends on how many came before it, so that count is part of its key.
|
|
57
|
+
const key = item.kind === "segment" ? `${item.revision}|${segment}` : item.revision;
|
|
58
|
+
const hit = options.cache?.get(item.id);
|
|
59
|
+
let view: ViewItem | undefined;
|
|
60
|
+
if (hit && hit.key === key) { view = hit.view; if (item.kind === "segment" && item.nextNode === undefined) segment++; }
|
|
61
|
+
else { view = projectOne(item, options, () => ++segment); options.cache?.set(item.id, { key, view }); }
|
|
62
|
+
if (view) out.push(view);
|
|
63
|
+
flush(index + 1);
|
|
64
|
+
});
|
|
65
|
+
while (notice < options.notices.length) out.push(options.notices[notice++]!.item);
|
|
66
|
+
const live = options.live;
|
|
67
|
+
if (live) {
|
|
68
|
+
if (live.bash !== null) out.push({ kind: "tool", id: "live:bash", rev: `${live.bash.length}`, name: "shell", summary: "running", status: "pending", detail: () => live.bash!.split("\n").slice(-40) });
|
|
69
|
+
const logged = new Set(items.flatMap(item => item.kind === "tool" ? [item.callId] : []));
|
|
70
|
+
for (const tool of live.tools) {
|
|
71
|
+
if (logged.has(tool.id)) continue;
|
|
72
|
+
let input: unknown;
|
|
73
|
+
try { input = JSON.parse(tool.arguments) as unknown; } catch { input = {}; }
|
|
74
|
+
out.push({ kind: "tool", id: `live:tool:${tool.id}`, rev: `${tool.arguments.length}`, name: tool.name, summary: toolSummary(tool.name, input) || "preparing…", status: "pending",
|
|
75
|
+
detail: width => toolDetail({ name: tool.name, input, result: null }, width, options.style?.()) });
|
|
76
|
+
}
|
|
77
|
+
if (live.output) out.push({ kind: "lines", id: "live:output", rev: `${live.output.length}`, render: () => live.output.split("\n").slice(-12) });
|
|
78
|
+
if (live.retry) out.push({ kind: "notice", id: "live:retry", rev: live.retry, text: live.retry, tone: "warning" });
|
|
79
|
+
if (live.thinking) out.push({ kind: "thinking", id: "live:thinking", rev: `${live.thinking.length}`, text: live.thinking, streaming: true, ...(options.hiddenThinkingLabel !== undefined ? { label: options.hiddenThinkingLabel } : {}) });
|
|
80
|
+
if (live.text) out.push({ kind: "assistant", id: "live:text", rev: `${live.text.length}`, text: live.text, streaming: true });
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function projectOne(item: TranscriptItem, options: ProjectOptions, nextSegment: () => number): ViewItem | undefined {
|
|
86
|
+
const base = { id: item.id, rev: item.revision };
|
|
87
|
+
switch (item.kind) {
|
|
88
|
+
case "user":
|
|
89
|
+
return { ...base, kind: "user", text: item.text, ...(item.images?.length ? { images: item.images.map(imageView) } : {}), ...(item.interrupted ? { interrupted: true } : {}) };
|
|
90
|
+
case "assistant": return item.text.trim() ? { ...base, kind: "assistant", text: item.text } : undefined;
|
|
91
|
+
case "thinking": return item.text.trim() ? { ...base, kind: "thinking", text: item.text, ...(options.hiddenThinkingLabel !== undefined ? { label: options.hiddenThinkingLabel } : {}) } : undefined;
|
|
92
|
+
case "notice": return { ...base, kind: "notice", text: item.text };
|
|
93
|
+
case "custom": {
|
|
94
|
+
const render = options.customMessage?.(item);
|
|
95
|
+
return render ? { ...base, kind: "lines", render } : { ...base, kind: "notice", text: `[${item.customType}] ${item.text}` };
|
|
96
|
+
}
|
|
97
|
+
case "tool": {
|
|
98
|
+
const custom = options.customTool?.(item);
|
|
99
|
+
const card = toolCard(item);
|
|
100
|
+
return { ...base, kind: "tool", name: item.name, summary: card.summary, status: card.status, ...(card.evidence ? { evidence: card.evidence } : {}),
|
|
101
|
+
...(custom ? {} : { preview: toolPreview(item) }), ...(item.result?.images?.length ? { images: item.result.images.map(imageView) } : {}),
|
|
102
|
+
detail: custom ?? (width => toolDetail(item, width, options.style?.())) };
|
|
103
|
+
}
|
|
104
|
+
case "shell": {
|
|
105
|
+
const code = item.exit; // the logged exit code is the evidence (D11)
|
|
106
|
+
// The log appends `exit N · status · reason` as the content's last line; the card's evidence already says it.
|
|
107
|
+
const output = item.output.replace(/^exit -?\d+[^\n]*\n?/, "").replace(/\n?exit -?\d+ · [^\n]*$/, "").replace(/\s+$/, "");
|
|
108
|
+
return { ...base, kind: "tool", name: item.excluded ? "!!" : "!", summary: item.command, status: code === 0 ? "ok" : "error", evidence: `exit ${code}`,
|
|
109
|
+
// Shell output keeps its last two lines in view while folded (refactor-tui-chrome-cleanup DoD 2).
|
|
110
|
+
preview: output.split("\n").filter(line => line.trim()).slice(-2),
|
|
111
|
+
// The exit code is already the card's evidence; the body is the output itself.
|
|
112
|
+
detail: () => (output || "(no output)").split("\n").slice(0, 200) };
|
|
113
|
+
}
|
|
114
|
+
case "segment": {
|
|
115
|
+
const node = item.nextNode ?? nextSegment();
|
|
116
|
+
return { ...base, kind: "segment", label: `node ${node} · ${SEGMENT_REASONS[item.reason] ?? item.reason}`,
|
|
117
|
+
detail: `handoff ${shortRef(item.handoff)}${item.nextModel ? ` → ${item.nextModel}` : ""} · history before this line is carried as structured state, not replayed` };
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function shortRef(ref: string): string { return ref.split("/").pop() ?? ref; }
|
|
123
|
+
|
|
124
|
+
/** What the view needs of a logged image: its address, name and pixel size. Never the bytes (D8). */
|
|
125
|
+
export function imageView(image: ImageRef): ImageView {
|
|
126
|
+
return { sha256: image.sha256, name: image.name, width: image.width, height: image.height };
|
|
127
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pickers that run before a session exists (startup session choice, project
|
|
3
|
+
* trust, `xeno config`). Each borrows the terminal for one dialog on its own
|
|
4
|
+
* renderer and gives it back; none of them creates a durable session.
|
|
5
|
+
* Same dialog component as the session UI, so keys and look match.
|
|
6
|
+
*/
|
|
7
|
+
import { BoxRenderable, createCliRenderer, type CliRenderer } from "@opentui/core";
|
|
8
|
+
import { defaultKeymap, type Keymap } from "../../core/keymap.ts";
|
|
9
|
+
import { nextResourceOverride } from "../../core/resource-selection.ts";
|
|
10
|
+
import { selectSessions } from "../../core/session-browser.ts";
|
|
11
|
+
import { builtinTheme, type Theme } from "../../core/theme.ts";
|
|
12
|
+
import { projectTrust } from "../../store/trust.ts";
|
|
13
|
+
import { discoverResources, setResourceOverride, type ResourceOptions } from "../../store/resource-index.ts";
|
|
14
|
+
import { loadUiResources } from "../../store/resources.ts";
|
|
15
|
+
import { registerSessionRoot, scanSessionRoots, sessionRoots } from "../../store/session-browser.ts";
|
|
16
|
+
import { sessionsDir, type ListedSession } from "../../store/sessions.ts";
|
|
17
|
+
import { fuzzyMatch } from "../../tui/fuzzy.ts";
|
|
18
|
+
import { DialogHost, type DialogSpec } from "../../tui/ui/dialog.ts";
|
|
19
|
+
import { keyPress } from "../../tui/ui/keys.ts";
|
|
20
|
+
import type { ListItem } from "../../tui/ui/list-model.ts";
|
|
21
|
+
import { Canvas } from "../../tui/ui/canvas.ts";
|
|
22
|
+
import { edgeLine } from "../../tui/ui/edge.ts";
|
|
23
|
+
import { applyTrustChoice, type TrustChoice } from "../project-trust.ts";
|
|
24
|
+
import { uiColors } from "./theme.ts";
|
|
25
|
+
import { trustItems } from "./app.ts";
|
|
26
|
+
|
|
27
|
+
interface Standalone { renderer: CliRenderer; dialogs: DialogHost; close(): void }
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A one-dialog screen: a heading row on top and the same input box the
|
|
31
|
+
* session uses, pinned to the bottom, with the dialog's title and keys on its
|
|
32
|
+
* top edge (refactor-tui-dock-layout). Nothing is painted behind it.
|
|
33
|
+
*/
|
|
34
|
+
async function open(theme: Theme, keymap: Keymap, heading: string, handoff?: () => void, renderer?: CliRenderer): Promise<Standalone> {
|
|
35
|
+
handoff?.();
|
|
36
|
+
const r = renderer ?? await createCliRenderer({ screenMode: "alternate-screen", exitOnCtrlC: false, exitSignals: [], consoleMode: "disabled", openConsoleOnError: false,
|
|
37
|
+
enableMouseMovement: false, gatherStats: false, memorySnapshotInterval: 0, maxFps: 60 });
|
|
38
|
+
// The terminal's palette is not known yet on this short-lived renderer; the
|
|
39
|
+
// theme's hint picks safe slots. The background is still never painted.
|
|
40
|
+
const colors = uiColors(theme);
|
|
41
|
+
r.setBackgroundColor(colors.background);
|
|
42
|
+
const root = new BoxRenderable(r, { id: "standalone", flexDirection: "column", width: "100%", height: "100%" });
|
|
43
|
+
const top = new Canvas(r, { id: "standalone-heading", height: 1, flexShrink: 0, marginLeft: 1, marginRight: 1, colors });
|
|
44
|
+
top.setRows([[{ text: "xeno", role: "text", bold: true }, { text: ` ${heading}`, role: "muted" }]]);
|
|
45
|
+
const spacer = new BoxRenderable(r, { id: "standalone-space", flexGrow: 1 });
|
|
46
|
+
const box = new BoxRenderable(r, { id: "standalone-box", flexDirection: "column", flexShrink: 0, marginLeft: 1, marginRight: 1 });
|
|
47
|
+
const topEdge = new Canvas(r, { id: "standalone-top", height: 1, flexShrink: 0, colors });
|
|
48
|
+
const body = new BoxRenderable(r, { id: "standalone-body", flexDirection: "column", flexShrink: 0, border: ["left", "right"], borderStyle: "rounded", borderColor: colors.edge, paddingLeft: 1, paddingRight: 1 });
|
|
49
|
+
const slot = new BoxRenderable(r, { id: "dialog", flexDirection: "column", flexShrink: 0, visible: false });
|
|
50
|
+
const bottomEdge = new Canvas(r, { id: "standalone-bottom", height: 1, flexShrink: 0, colors });
|
|
51
|
+
body.add(slot); box.add(topEdge); box.add(body); box.add(bottomEdge);
|
|
52
|
+
root.add(top); root.add(spacer); root.add(box);
|
|
53
|
+
r.root.add(root);
|
|
54
|
+
const dialogs = new DialogHost(r, colors, () => keymap.byAction, slot, () => Math.max(1, r.height - 4));
|
|
55
|
+
const paint = () => {
|
|
56
|
+
const width = Math.max(2, r.width - 2);
|
|
57
|
+
topEdge.setRows([edgeLine({ width, corners: ["╭", "╮"], left: dialogs.title ? [{ segs: [{ text: dialogs.title, role: "text", bold: true }], priority: 0, min: 8 }] : [],
|
|
58
|
+
right: dialogs.edgeHint ? [{ segs: [{ text: dialogs.edgeHint, role: "muted" }], priority: 3, min: 10 }] : [] })]);
|
|
59
|
+
bottomEdge.setRows([edgeLine({ width, corners: ["╰", "╯"] })]);
|
|
60
|
+
};
|
|
61
|
+
dialogs.onChrome = paint;
|
|
62
|
+
r.on("resize", () => { dialogs.layout(); paint(); });
|
|
63
|
+
paint();
|
|
64
|
+
r.keyInput.on("keypress", event => { if (dialogs.key(keyPress(event))) event.preventDefault(); });
|
|
65
|
+
r.keyInput.on("paste", event => { if (dialogs.paste(new TextDecoder().decode(event.bytes)) === false) event.preventDefault(); });
|
|
66
|
+
return { renderer: r, dialogs, close: () => { dialogs.dispose(); if (!renderer) r.destroy(); } };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Show one dialog; resolves with the picked value, or null when it is closed without a choice. */
|
|
70
|
+
function ask(host: Standalone, spec: (resolve: (value: string | null) => void) => DialogSpec): Promise<string | null> {
|
|
71
|
+
return new Promise(resolve => {
|
|
72
|
+
let settled = false;
|
|
73
|
+
const done = (value: string | null) => { if (!settled) { settled = true; resolve(value); } };
|
|
74
|
+
// A pick empties the stack before its callback runs; let the callback answer first.
|
|
75
|
+
host.dialogs.onClosed = () => queueMicrotask(() => done(null));
|
|
76
|
+
host.dialogs.push(spec(done));
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function pickProjectTrust(workspace: string, handoff?: () => void, renderer?: CliRenderer): Promise<boolean> {
|
|
81
|
+
const decision = await projectTrust(workspace);
|
|
82
|
+
const ui = await loadUiResources({ workspace, trusted: false });
|
|
83
|
+
const host = await open(ui.theme, ui.keymap, "project trust", handoff, renderer);
|
|
84
|
+
try {
|
|
85
|
+
const choice = await ask(host, resolve => ({ kind: "list", title: "Trust this project?", search: false, items: trustItems(workspace),
|
|
86
|
+
intro: [workspace, "This project has its own .xeno configuration (settings, skills, extensions). Tool permissions are separate."],
|
|
87
|
+
...(decision.saved ? { selected: decision.saved.trusted ? decision.saved.path === workspace ? "here" : "parent" : "never" } : {}),
|
|
88
|
+
onPick: item => resolve(item.value) }));
|
|
89
|
+
return choice === null ? false : applyTrustChoice(workspace, choice as TrustChoice);
|
|
90
|
+
} finally { host.close(); }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
type Session = Extract<ListedSession, { ok: true }>;
|
|
94
|
+
|
|
95
|
+
export async function pickStartupSession(opts: { workspace: string; sessionDir?: string; say: (line: string) => void; keymap?: Keymap; theme?: Theme },
|
|
96
|
+
handoff?: () => void, renderer?: CliRenderer): Promise<string | null> {
|
|
97
|
+
const root = { path: sessionsDir(opts.workspace, opts.sessionDir), workspace: opts.workspace };
|
|
98
|
+
await registerSessionRoot(root).catch(error => opts.say(String(error)));
|
|
99
|
+
const host = await open(opts.theme ?? builtinTheme("dark"), opts.keymap ?? defaultKeymap(), "resume a session", handoff, renderer);
|
|
100
|
+
const scan = new AbortController();
|
|
101
|
+
let rows: Session[] = [];
|
|
102
|
+
let scope: "current" | "all" = "current";
|
|
103
|
+
const items = (query = ""): ListItem[] => selectSessions(rows, query, "threaded", false, (q, text) => { const score = fuzzyMatch(text, q); return { matches: score !== null, score: score ?? Infinity }; })
|
|
104
|
+
.map(row => ({ value: row.path, label: " ".repeat(Math.min(8, row.depth)) + (row.depth ? "↳ " : "") + (row.name?.trim() || row.text?.split("\n").find(Boolean)?.slice(0, 90) || row.id),
|
|
105
|
+
description: [row.updated_at?.slice(0, 16).replace("T", " ") ?? "", row.model ?? "", scope === "all" ? row.workspace ?? "" : ""].filter(Boolean).join(" · ") }));
|
|
106
|
+
try {
|
|
107
|
+
let spec: DialogSpec & { kind: "list" } | undefined;
|
|
108
|
+
const load = async () => {
|
|
109
|
+
rows = [];
|
|
110
|
+
for await (const batch of scanSessionRoots(scope === "current" ? [root] : await sessionRoots(root), scan.signal)) {
|
|
111
|
+
rows.push(...batch.sessions.filter((row): row is Session => row.ok));
|
|
112
|
+
if (spec && host.dialogs.top === spec) host.dialogs.list?.setItems(items(host.dialogs.list.query));
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
const picked = ask(host, resolve => (spec = { kind: "list", title: "Resume · this folder", items: [], rows: 16, empty: "Loading sessions…", placeholder: "search sessions",
|
|
116
|
+
actions: [{ key: "tab", label: "all workspaces", run: async () => { scope = scope === "current" ? "all" : "current"; spec!.title = `Resume · ${scope === "current" ? "this folder" : "all workspaces"}`; await load(); } }],
|
|
117
|
+
onPick: item => resolve(item.value) }));
|
|
118
|
+
await load();
|
|
119
|
+
if (!rows.length && scope === "current" && (await sessionRoots(root)).length === 1) { opts.say("No sessions to resume"); host.dialogs.clear(); return null; }
|
|
120
|
+
return await picked;
|
|
121
|
+
} finally { scan.abort(); host.close(); }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export async function runResourceConfig(options: ResourceOptions, handoff?: () => void, renderer?: CliRenderer): Promise<void> {
|
|
125
|
+
let index = await discoverResources(options);
|
|
126
|
+
const ui = await loadUiResources(options, index);
|
|
127
|
+
const host = await open(ui.theme, ui.keymap, "resources", handoff, renderer);
|
|
128
|
+
let scope: "user" | "project" = options.local ? "project" : "user";
|
|
129
|
+
const items = (): ListItem[] => index.entries.map(entry => ({ value: `${entry.kind}:${entry.path}`, label: `${entry.enabled ? "◉" : "○"} ${entry.name}`, section: entry.kind,
|
|
130
|
+
description: `${entry.scope}${entry.trusted ? "" : " · untrusted"}${scope === "project" ? ` · ${entry.override}` : ""}`, keywords: entry.path }));
|
|
131
|
+
try {
|
|
132
|
+
let spec: DialogSpec & { kind: "list" } | undefined;
|
|
133
|
+
await ask(host, () => (spec = { kind: "list", title: `Resources · ${scope === "user" ? "global" : "project"}`, items: items(), rows: 16, empty: "No resources found",
|
|
134
|
+
hint: "space toggle · tab scope · esc close",
|
|
135
|
+
actions: [
|
|
136
|
+
{ key: "tab", label: "scope", run: () => { scope = scope === "user" ? "project" : "user"; spec!.title = `Resources · ${scope === "user" ? "global" : "project"}`; host.dialogs.list?.setItems(items()); } },
|
|
137
|
+
{ key: "space", label: "toggle", run: async item => {
|
|
138
|
+
const entry = index.entries.find(row => `${row.kind}:${row.path}` === item?.value);
|
|
139
|
+
if (!entry) return;
|
|
140
|
+
await setResourceOverride(index, entry, scope, scope === "project" ? nextResourceOverride(entry.override) : entry.inherited ? "skip" : "load");
|
|
141
|
+
index = await discoverResources(options);
|
|
142
|
+
host.dialogs.list?.setItems(items(), item?.value);
|
|
143
|
+
} },
|
|
144
|
+
] }));
|
|
145
|
+
} finally { host.close(); }
|
|
146
|
+
}
|