@visual-z/xeno 0.1.3 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (148) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +143 -33
  3. package/docs/install.md +18 -6
  4. package/docs/providers.md +147 -0
  5. package/package.json +8 -2
  6. package/src/apps/cli.ts +42 -14
  7. package/src/apps/commands.ts +32 -4
  8. package/src/apps/completions.ts +9 -6
  9. package/src/apps/export-cli.ts +24 -0
  10. package/src/apps/extension-ui.ts +330 -0
  11. package/src/apps/input.ts +16 -1
  12. package/src/apps/model-catalog.ts +42 -0
  13. package/src/apps/project-trust.ts +49 -0
  14. package/src/apps/render.ts +1 -0
  15. package/src/apps/resource-browser.ts +75 -0
  16. package/src/apps/resources.ts +25 -0
  17. package/src/apps/session-browser.ts +113 -0
  18. package/src/apps/session-picker.ts +55 -0
  19. package/src/apps/shell.ts +1177 -378
  20. package/src/apps/startup.ts +400 -268
  21. package/src/core/action-defaults.ts +306 -0
  22. package/src/core/attachment-input.ts +17 -0
  23. package/src/core/catalog-merge.ts +19 -0
  24. package/src/core/event-validation.ts +19 -2
  25. package/src/core/events.ts +45 -2
  26. package/src/core/export.ts +6 -2
  27. package/src/core/extension-flags.ts +14 -0
  28. package/src/core/images.ts +46 -0
  29. package/src/core/json-event.ts +2 -0
  30. package/src/core/keymap.ts +43 -119
  31. package/src/core/model-replay.ts +22 -0
  32. package/src/core/model-scope.ts +53 -0
  33. package/src/core/packages.ts +47 -0
  34. package/src/core/progress.ts +9 -0
  35. package/src/core/project.ts +21 -3
  36. package/src/core/prompts.ts +33 -5
  37. package/src/core/queue.ts +18 -0
  38. package/src/core/resource-selection.ts +36 -0
  39. package/src/{runtime → core}/resume.ts +15 -22
  40. package/src/core/session-archive.ts +4 -0
  41. package/src/core/session-browser.ts +82 -0
  42. package/src/core/session-entry.ts +56 -0
  43. package/src/core/session-position.ts +26 -0
  44. package/src/core/settings.ts +169 -154
  45. package/src/core/theme.ts +15 -6
  46. package/src/core/thinking.ts +26 -0
  47. package/src/core/tokens.ts +2 -0
  48. package/src/core/transcript.ts +15 -5
  49. package/src/runtime/auth.ts +116 -11
  50. package/src/runtime/catalog.ts +54 -151
  51. package/src/runtime/clipboard.ts +114 -0
  52. package/src/runtime/extension-loader.ts +37 -0
  53. package/src/runtime/extension-ui.ts +55 -0
  54. package/src/runtime/extensions.ts +273 -0
  55. package/src/runtime/images.ts +76 -0
  56. package/src/runtime/loop.ts +213 -83
  57. package/src/runtime/model-config.ts +232 -0
  58. package/src/runtime/model-data.LICENSE +27 -0
  59. package/src/runtime/model-data.ts +21 -0
  60. package/src/runtime/model-replay.ts +10 -0
  61. package/src/runtime/model-scope.ts +20 -0
  62. package/src/runtime/model-sources.ts +151 -0
  63. package/src/runtime/model.ts +74 -11
  64. package/src/runtime/models-fetch.ts +6 -104
  65. package/src/runtime/oauth.ts +4 -3
  66. package/src/runtime/openai-responses.ts +12 -4
  67. package/src/runtime/openai.ts +13 -4
  68. package/src/runtime/package-manager.ts +125 -0
  69. package/src/runtime/pi-provider.ts +235 -0
  70. package/src/runtime/progress.ts +26 -0
  71. package/src/runtime/provider-access.ts +21 -0
  72. package/src/runtime/provider-protocols.ts +29 -0
  73. package/src/runtime/provider-request.ts +73 -0
  74. package/src/runtime/provider.ts +26 -6
  75. package/src/runtime/rich-text.ts +36 -0
  76. package/src/runtime/tools.ts +41 -5
  77. package/src/store/command.ts +7 -1
  78. package/src/store/context-files.ts +1 -1
  79. package/src/store/log.ts +65 -0
  80. package/src/store/models.ts +28 -9
  81. package/src/store/observations.ts +4 -2
  82. package/src/store/packages.ts +98 -0
  83. package/src/store/recovery.ts +8 -0
  84. package/src/store/resource-index.ts +170 -0
  85. package/src/store/resources.ts +75 -142
  86. package/src/store/session-archive.ts +162 -0
  87. package/src/store/session-browser.ts +68 -0
  88. package/src/store/session-position.ts +27 -0
  89. package/src/store/sessions.ts +87 -66
  90. package/src/store/trust.ts +13 -0
  91. package/src/store/workspace.ts +6 -0
  92. package/src/tui/chat-viewport.ts +47 -0
  93. package/src/tui/editor.ts +62 -15
  94. package/src/tui/host.ts +50 -9
  95. package/src/tui/input.ts +8 -2
  96. package/src/tui/select.ts +24 -6
  97. package/src/tui/settings.ts +4 -0
  98. package/src/tui/system-theme.ts +700 -0
  99. package/src/tui/transcript.ts +63 -11
  100. package/src/tui/tree.ts +161 -95
  101. package/src/vendor/pi-rich/LICENSE.upstream +21 -0
  102. package/src/vendor/pi-rich/UPSTREAM.json +17 -0
  103. package/src/vendor/pi-rich/clipboard-command.ts +44 -0
  104. package/src/vendor/pi-rich/exif-orientation.ts +182 -0
  105. package/src/vendor/pi-rich/html.ts +51 -0
  106. package/src/vendor/pi-rich/image-convert.ts +49 -0
  107. package/src/vendor/pi-rich/image-resize-core.ts +164 -0
  108. package/src/vendor/pi-rich/image-resize-worker.ts +42 -0
  109. package/src/vendor/pi-rich/image-resize.ts +123 -0
  110. package/src/vendor/pi-rich/mime.ts +116 -0
  111. package/src/vendor/pi-rich/photon.ts +139 -0
  112. package/src/vendor/pi-rich/syntax-highlight.ts +214 -0
  113. package/src/vendor/pi-tui/UPSTREAM.json +50 -0
  114. package/src/vendor/pi-tui/VENDOR.md +7 -5
  115. package/src/vendor/pi-tui/autocomplete.ts +35 -10
  116. package/src/vendor/pi-tui/colors.ts +367 -0
  117. package/src/vendor/pi-tui/components/box.ts +8 -9
  118. package/src/vendor/pi-tui/components/editor.ts +4 -2
  119. package/src/vendor/pi-tui/components/markdown.ts +8 -2
  120. package/src/vendor/pi-tui/index.ts +31 -1
  121. package/src/vendor/pi-tui/native/UPSTREAM.json +23 -0
  122. package/src/vendor/pi-tui/native/clipboard.h +208 -0
  123. package/src/vendor/pi-tui/native/darwin/README.md +22 -0
  124. package/src/vendor/pi-tui/native/darwin/build.sh +69 -0
  125. package/src/vendor/pi-tui/native/darwin/prebuilds/darwin-arm64/darwin-platform.node +0 -0
  126. package/src/vendor/pi-tui/native/darwin/prebuilds/darwin-x64/darwin-platform.node +0 -0
  127. package/src/vendor/pi-tui/native/darwin/src/darwin-platform.m +132 -0
  128. package/src/vendor/pi-tui/native/linux/README.md +23 -0
  129. package/src/vendor/pi-tui/native/linux/build.sh +65 -0
  130. package/src/vendor/pi-tui/native/linux/prebuilds/linux-arm64/linux-platform-x11.node +0 -0
  131. package/src/vendor/pi-tui/native/linux/prebuilds/linux-x64/linux-platform-x11.node +0 -0
  132. package/src/vendor/pi-tui/native/linux/src/clipboard-worker.h +70 -0
  133. package/src/vendor/pi-tui/native/linux/src/linux-platform-x11.c +274 -0
  134. package/src/vendor/pi-tui/native/napi.h +91 -0
  135. package/src/vendor/pi-tui/native/win32/README.md +31 -0
  136. package/src/vendor/pi-tui/native/win32/build.mjs +232 -0
  137. package/src/vendor/pi-tui/native/win32/prebuilds/win32-arm64/win32-platform.node +0 -0
  138. package/src/vendor/pi-tui/native/win32/prebuilds/win32-x64/win32-platform.node +0 -0
  139. package/src/vendor/pi-tui/native/win32/src/win32-platform.c +241 -0
  140. package/src/vendor/pi-tui/native-platform.ts +2 -0
  141. package/src/vendor/pi-tui/oklab.ts +233 -0
  142. package/src/vendor/pi-tui/terminal-colors.ts +26 -8
  143. package/src/vendor/pi-tui/terminal-image.ts +43 -8
  144. package/src/vendor/pi-tui/terminal.ts +16 -9
  145. package/src/vendor/pi-tui/tui-alt-screen.ts +26 -18
  146. package/src/vendor/pi-tui/tui.ts +112 -75
  147. package/src/vendor/pi-tui/utils.ts +89 -49
  148. package/src/vendor/pi-tui/wheel-scroll.ts +82 -0
@@ -0,0 +1,82 @@
1
+ /** Pi session-selector-search (MIT, 02eed88), independent of its AgentSession and clock. */
2
+ export type SessionSort = "threaded" | "recent" | "relevance";
3
+ export interface SessionRow {
4
+ path: string; id: string; name: string | null; updated_at: string | null;
5
+ workspace?: string; parent_session?: string; text?: string;
6
+ }
7
+ export interface SearchQuery {
8
+ tokens: { kind: "fuzzy" | "phrase"; value: string }[];
9
+ regex?: RegExp;
10
+ error?: string;
11
+ }
12
+ export function parseSessionQuery(query: string): SearchQuery {
13
+ const trimmed = query.trim();
14
+ if (trimmed.startsWith("re:")) {
15
+ if (!trimmed.slice(3).trim()) return { tokens: [], error: "Empty regex" };
16
+ try { return { tokens: [], regex: new RegExp(trimmed.slice(3).trim(), "i") }; }
17
+ catch (error) { return { tokens: [], error: String(error) }; }
18
+ }
19
+ const tokens: SearchQuery["tokens"] = [];
20
+ let buffer = "", quoted = false;
21
+ const flush = () => { if (buffer.trim()) tokens.push({ kind: quoted ? "phrase" : "fuzzy", value: buffer.trim() }); buffer = ""; };
22
+ for (const char of trimmed) {
23
+ if (char === '"') { flush(); quoted = !quoted; }
24
+ else if (!quoted && /\s/.test(char)) flush();
25
+ else buffer += char;
26
+ }
27
+ if (quoted) return { tokens: trimmed.split(/\s+/).filter(Boolean).map(value => ({ kind: "fuzzy", value })) };
28
+ flush(); return { tokens };
29
+ }
30
+ export function selectSessions<T extends SessionRow>(rows: readonly T[], query: string, sort: SessionSort, named: boolean,
31
+ fuzzy: (query: string, text: string) => { matches: boolean; score: number }): (T & { depth: number })[] {
32
+ const parsed = parseSessionQuery(query);
33
+ if (parsed.error) return [];
34
+ const scored = rows.flatMap(row => {
35
+ if (named && !row.name?.trim()) return [];
36
+ const text = `${row.id} ${row.name ?? ""} ${row.text ?? ""} ${row.workspace ?? ""}`;
37
+ let score = 0;
38
+ if (parsed.regex) { const i = text.search(parsed.regex); if (i < 0) return []; score = i * 0.1; }
39
+ else for (const token of parsed.tokens) {
40
+ if (token.kind === "phrase") {
41
+ const i = text.toLowerCase().replace(/\s+/g, " ").indexOf(token.value.toLowerCase().replace(/\s+/g, " "));
42
+ if (i < 0) return []; score += i * 0.1;
43
+ } else { const match = fuzzy(token.value, text); if (!match.matches) return []; score += match.score; }
44
+ }
45
+ return [{ row, score }];
46
+ });
47
+ const recent = (a: SessionRow, b: SessionRow) => (b.updated_at ?? "").localeCompare(a.updated_at ?? "") || a.path.localeCompare(b.path);
48
+ scored.sort((a, b) => query.trim() && sort !== "recent" ? a.score - b.score || recent(a.row, b.row) : recent(a.row, b.row));
49
+ if (sort !== "threaded" || query.trim()) return scored.map(({ row }) => ({ ...row, depth: 0 }));
50
+ const byPath = new Map(scored.map(({ row }) => [row.path, row]));
51
+ const children = new Map<string, T[]>();
52
+ const roots: T[] = [];
53
+ for (const { row } of scored) {
54
+ if (row.parent_session && row.parent_session !== row.path && byPath.has(row.parent_session)) {
55
+ const list = children.get(row.parent_session) ?? []; list.push(row); children.set(row.parent_session, list);
56
+ } else roots.push(row);
57
+ }
58
+ // Allocation can be arbitrarily deep. Iterative traversal handles cycles without hiding their rows.
59
+ const latest = new Map(scored.map(({ row }) => [row.path, row.updated_at ?? ""]));
60
+ for (const { row } of scored) {
61
+ const visited = new Set<string>([row.path]);
62
+ let parent = row.parent_session;
63
+ while (parent && byPath.has(parent) && !visited.has(parent)) {
64
+ visited.add(parent);
65
+ if ((row.updated_at ?? "") > (latest.get(parent) ?? "")) latest.set(parent, row.updated_at ?? "");
66
+ parent = byPath.get(parent)?.parent_session;
67
+ }
68
+ }
69
+ const byActivity = (a: T, b: T) => (latest.get(b.path) ?? "").localeCompare(latest.get(a.path) ?? "") || recent(a, b);
70
+ roots.sort(byActivity); for (const list of children.values()) list.sort(byActivity);
71
+ const out: (T & { depth: number })[] = [], visited = new Set<string>();
72
+ for (const root of [...roots, ...scored.map(s => s.row)]) {
73
+ const stack = [{ row: root, depth: 0 }];
74
+ while (stack.length) {
75
+ const next = stack.pop()!;
76
+ if (visited.has(next.row.path)) continue;
77
+ visited.add(next.row.path); out.push({ ...next.row, depth: next.depth });
78
+ for (const row of [...children.get(next.row.path) ?? []].reverse()) stack.push({ row, depth: next.depth + 1 });
79
+ }
80
+ }
81
+ return out;
82
+ }
@@ -0,0 +1,56 @@
1
+ /** Session intent is decided before any log is opened (D8, Pi entry parity). */
2
+ export interface SessionEntryFlags {
3
+ explicit?: string;
4
+ fresh?: boolean;
5
+ continueLatest?: boolean;
6
+ noSession?: boolean;
7
+ session?: string;
8
+ sessionId?: string;
9
+ resume?: string | boolean;
10
+ forkFrom?: string;
11
+ }
12
+
13
+ export type SessionEntry =
14
+ | { kind: "new" }
15
+ | { kind: "ephemeral" }
16
+ | { kind: "continue" }
17
+ | { kind: "select" }
18
+ | { kind: "named"; id: string }
19
+ | { kind: "open"; target: string; explicit: boolean }
20
+ | { kind: "fork"; target: string };
21
+
22
+ export function planSessionEntry(flags: SessionEntryFlags): SessionEntry {
23
+ const choices: { flag: string; entry: SessionEntry }[] = [];
24
+ if (flags.explicit) choices.push({ flag: "--log", entry: { kind: "open", target: flags.explicit, explicit: true } });
25
+ if (flags.session) choices.push({ flag: "--session", entry: { kind: "open", target: flags.session, explicit: false } });
26
+ if (flags.sessionId) {
27
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$/.test(flags.sessionId)) throw new Error("--session-id requires a plain session identifier");
28
+ choices.push({ flag: "--session-id", entry: { kind: "named", id: flags.sessionId } });
29
+ }
30
+ if (flags.resume) choices.push({ flag: "--resume", entry: typeof flags.resume === "string"
31
+ ? { kind: "open", target: flags.resume, explicit: false } : { kind: "select" } });
32
+ if (flags.forkFrom) choices.push({ flag: "--fork", entry: { kind: "fork", target: flags.forkFrom } });
33
+ if (flags.continueLatest) choices.push({ flag: "--continue", entry: { kind: "continue" } });
34
+ // --fresh predates --no-session; together they still mean a fresh ephemeral log.
35
+ if (flags.fresh || flags.noSession) choices.push({ flag: flags.noSession ? "--no-session" : "--fresh",
36
+ entry: { kind: flags.noSession ? "ephemeral" : "new" } });
37
+ if (choices.length > 1) throw new Error(`conflicting session options: ${choices.map((c) => c.flag).join(", ")}`);
38
+ return choices[0]?.entry ?? { kind: "new" };
39
+ }
40
+
41
+ export interface SavedModel {
42
+ model: string;
43
+ provider?: string;
44
+ }
45
+
46
+ /** Legacy logs have only an ID; accepting one requires a unique catalog match. */
47
+ export function restoreSessionModel<T extends { id: string; provider: string }>(
48
+ saved: SavedModel,
49
+ catalog: readonly T[],
50
+ ): T {
51
+ const matches = catalog.filter((m) => m.id === saved.model && (saved.provider === undefined || m.provider === saved.provider));
52
+ const label = saved.provider === undefined ? saved.model : `${saved.provider}/${saved.model}`;
53
+ if (matches.length === 0) throw new Error(`saved model ${label} is unavailable. Restore its catalog entry, or start a new session`);
54
+ if (matches.length !== 1) throw new Error(`saved model ${label} is ambiguous across providers. Restore an unambiguous catalog before resuming`);
55
+ return matches[0]!;
56
+ }
@@ -0,0 +1,26 @@
1
+ /** D8: session configuration is projected from the append-only metadata journal. */
2
+ export interface PositionedState { node: number; at_event?: string }
3
+
4
+ export function stateAtPosition<T extends PositionedState>(
5
+ states: readonly T[], node: number, eventIds: readonly string[], atEvent?: string,
6
+ ): T | null {
7
+ const positions = new Map(eventIds.map((id, index) => [id, index]));
8
+ const target = atEvent === undefined ? eventIds.length : positions.get(atEvent);
9
+ if (target === undefined) throw new Error(`NO_SUCH_EVENT: ${atEvent}`);
10
+ let result: T | null = null;
11
+ for (const state of states) {
12
+ if (state.node !== node) continue;
13
+ const position = state.at_event === undefined ? -1 : positions.get(state.at_event);
14
+ if (position !== undefined && position <= target) result = state;
15
+ }
16
+ return result;
17
+ }
18
+
19
+ export function sameModel(a: { model: string; provider?: string }, b: { model: string; provider?: string }): boolean {
20
+ return a.model === b.model && a.provider !== undefined && a.provider === b.provider;
21
+ }
22
+
23
+ /** Selector values must remain unambiguous when two providers serve the same ID. */
24
+ export function modelKey(model: { provider: string; id: string }): string {
25
+ return `${model.provider}/${model.id}`;
26
+ }
@@ -1,174 +1,189 @@
1
- /**
2
- * Settings schema and merge. Pure: store reads the files, this module
3
- * decides which keys exist and which layer wins.
4
- *
5
- * Write-back is a patch of keys the user actually changed. Dumping the
6
- * whole default object into ~/.xeno/settings.json freezes today's defaults
7
- * for that user forever (feat-theming design).
8
- */
9
-
10
- export const SETTINGS_KEYS = [
11
- "theme",
12
- "thinking",
13
- "statusBar",
14
- "defaultProvider",
15
- "defaultModel",
16
- "defaultThinking",
17
- "scopedModels",
18
- ] as const;
19
-
20
- export type SettingsKey = (typeof SETTINGS_KEYS)[number];
1
+ import { THINKING_LEVELS, asThinkingLevel, type ThinkingLevel } from "./thinking.ts";
2
+ import { packageDeclaration, type PackageDeclaration } from "./packages.ts";
21
3
 
4
+ /** Validated configuration. D19: display policy never mutates the log. */
22
5
  export interface Settings {
23
6
  readonly theme?: string;
24
7
  readonly thinking?: string;
25
8
  readonly statusBar?: boolean;
9
+ readonly hideThinkingBlock?: boolean;
10
+ readonly showCacheMissNotices?: boolean;
26
11
  readonly defaultProvider?: string;
27
12
  readonly defaultModel?: string;
28
- /** Ctrl+S in the thinking selector. Distinct from `thinking` so /settings does not overwrite a saved default. */
29
13
  readonly defaultThinking?: string;
30
- /** `--models` / Ctrl+P set. Absent means there is nothing to cycle. */
14
+ readonly modelThinkingLevels?: Readonly<Record<string, ThinkingLevel>>;
31
15
  readonly scopedModels?: readonly string[];
16
+ readonly steeringMode?: "all" | "one-at-a-time";
17
+ readonly followUpMode?: "all" | "one-at-a-time";
18
+ readonly tuiMode?: "regular" | "fullscreen";
19
+ readonly fullscreenExitOutput?: "transcript" | "resume-hint";
20
+ readonly fullscreenScrollbar?: "auto" | "always" | "hidden";
21
+ readonly fullscreenCopyOnSelect?: boolean;
22
+ readonly fullscreenWheelScrollLines?: "auto" | number;
23
+ readonly editorPaddingX?: number;
24
+ readonly outputPad?: 0 | 1;
25
+ readonly autocompleteMaxVisible?: number;
26
+ readonly showHardwareCursor?: boolean;
27
+ readonly quietStartup?: boolean;
28
+ readonly doubleEscapeAction?: "tree" | "fork" | "none";
29
+ readonly treeFilterMode?: "default" | "no-tools" | "user-only" | "labeled-only" | "all";
30
+ readonly externalEditor?: string;
31
+ readonly skills?: readonly string[];
32
+ readonly prompts?: readonly string[];
33
+ readonly themes?: readonly string[];
34
+ readonly extensions?: readonly string[];
35
+ readonly packages?: readonly PackageDeclaration[];
36
+ readonly enableSkillCommands?: boolean;
37
+ readonly defaultProjectTrust?: "ask" | "always" | "never";
38
+ readonly terminal?: { readonly clearOnShrink?: boolean; readonly showTerminalProgress?: boolean; readonly showImages?: boolean; readonly imageWidthCells?: number };
39
+ readonly images?: { readonly autoResize?: boolean; readonly blockImages?: boolean };
40
+ readonly markdown?: { readonly mermaid?: "off" | "final" | "streaming" };
32
41
  }
33
-
34
- export interface SettingsLayers {
35
- readonly builtin: Settings;
36
- readonly user?: Settings;
37
- readonly project?: Settings;
38
- readonly cli?: Settings;
39
- }
40
-
41
- export interface SettingsParse {
42
- readonly settings: Settings;
43
- readonly diagnostics: readonly string[];
44
- }
45
-
46
- const THINKING_LEVELS = new Set(["low", "medium", "high", "xhigh", "max"]);
47
-
48
- export function defaultSettings(): Settings {
49
- return { theme: "dark", thinking: "high", statusBar: true };
50
- }
51
-
42
+ export const SETTINGS_KEYS = ["theme", "thinking", "statusBar", "hideThinkingBlock", "showCacheMissNotices", "defaultProvider", "defaultModel", "defaultThinking",
43
+ "modelThinkingLevels", "scopedModels", "steeringMode", "followUpMode", "tuiMode", "fullscreenExitOutput", "fullscreenScrollbar",
44
+ "fullscreenCopyOnSelect", "fullscreenWheelScrollLines", "editorPaddingX", "outputPad", "autocompleteMaxVisible",
45
+ "showHardwareCursor", "quietStartup", "doubleEscapeAction", "treeFilterMode", "externalEditor", "terminal", "images", "markdown",
46
+ "skills", "prompts", "themes", "extensions", "packages", "enableSkillCommands", "defaultProjectTrust"] as const;
47
+ export type SettingsKey = (typeof SETTINGS_KEYS)[number];
48
+ export interface SettingsLayers { readonly builtin: Settings; readonly user?: Settings; readonly project?: Settings; readonly cli?: Settings }
49
+ export interface SettingsParse { readonly settings: Settings; readonly diagnostics: readonly string[] }
50
+ type Rule = { readonly kind: "boolean" | "string" | "strings" } | { readonly kind: "enum"; readonly values: readonly string[] } |
51
+ { readonly kind: "integer"; readonly min: number; readonly max: number; readonly auto?: boolean };
52
+ const levels: Rule = { kind: "enum", values: THINKING_LEVELS };
53
+ const delivery: Rule = { kind: "enum", values: ["all", "one-at-a-time"] };
54
+ const rules: Readonly<Record<string, Rule>> = {
55
+ theme: { kind: "string" }, thinking: levels, statusBar: { kind: "boolean" },
56
+ hideThinkingBlock: { kind: "boolean" }, showCacheMissNotices: { kind: "boolean" },
57
+ defaultProvider: { kind: "string" }, defaultModel: { kind: "string" }, defaultThinking: levels,
58
+ scopedModels: { kind: "strings" }, steeringMode: delivery, followUpMode: delivery,
59
+ tuiMode: { kind: "enum", values: ["regular", "fullscreen"] },
60
+ fullscreenExitOutput: { kind: "enum", values: ["transcript", "resume-hint"] },
61
+ fullscreenScrollbar: { kind: "enum", values: ["auto", "always", "hidden"] },
62
+ fullscreenCopyOnSelect: { kind: "boolean" },
63
+ // Pi's documented display bounds; these are not context/segment thresholds.
64
+ fullscreenWheelScrollLines: { kind: "integer", min: 1, max: 100, auto: true },
65
+ editorPaddingX: { kind: "integer", min: 0, max: 3 }, outputPad: { kind: "integer", min: 0, max: 1 },
66
+ autocompleteMaxVisible: { kind: "integer", min: 3, max: 20 },
67
+ showHardwareCursor: { kind: "boolean" }, quietStartup: { kind: "boolean" },
68
+ doubleEscapeAction: { kind: "enum", values: ["tree", "fork", "none"] },
69
+ treeFilterMode: { kind: "enum", values: ["default", "no-tools", "user-only", "labeled-only", "all"] },
70
+ externalEditor: { kind: "string" }, "terminal.clearOnShrink": { kind: "boolean" },
71
+ "terminal.showTerminalProgress": { kind: "boolean" },
72
+ "terminal.showImages": { kind: "boolean" }, "terminal.imageWidthCells": { kind: "integer", min: 1, max: 240 },
73
+ "images.autoResize": { kind: "boolean" }, "images.blockImages": { kind: "boolean" },
74
+ "markdown.mermaid": { kind: "enum", values: ["off", "final", "streaming"] },
75
+ skills: { kind: "strings" }, prompts: { kind: "strings" }, themes: { kind: "strings" }, extensions: { kind: "strings" },
76
+ enableSkillCommands: { kind: "boolean" }, defaultProjectTrust: { kind: "enum", values: ["ask", "always", "never"] },
77
+ };
78
+ /** One catalog supplies menu values and command validation. */
79
+ export const DISPLAY_SETTINGS = [
80
+ { id: "hideThinkingBlock", label: "Hide thinking blocks", fallback: "on" },
81
+ { id: "showCacheMissNotices", label: "Cache miss notices", fallback: "off" },
82
+ { id: "enableSkillCommands", label: "Skill commands", fallback: "on" },
83
+ { id: "defaultProjectTrust", label: "Default project trust", fallback: "ask" },
84
+ { id: "terminal.showImages", label: "Show images", fallback: "on" },
85
+ { id: "terminal.imageWidthCells", label: "Image width", fallback: "60" },
86
+ { id: "images.autoResize", label: "Resize images", fallback: "on" },
87
+ { id: "images.blockImages", label: "Block image transmission", fallback: "off" },
88
+ { id: "markdown.mermaid", label: "Mermaid diagrams", fallback: "streaming" },
89
+ { id: "tuiMode", label: "TUI mode", fallback: "regular" },
90
+ { id: "fullscreenExitOutput", label: "Fullscreen exit output", fallback: "transcript" },
91
+ { id: "fullscreenScrollbar", label: "Fullscreen scrollbar", fallback: "auto" },
92
+ { id: "fullscreenCopyOnSelect", label: "Fullscreen copy on select", fallback: "on" },
93
+ { id: "fullscreenWheelScrollLines", label: "Fullscreen wheel scroll lines", fallback: "auto" },
94
+ { id: "editorPaddingX", label: "Editor padding", fallback: "0" },
95
+ { id: "outputPad", label: "Output padding", fallback: "1" },
96
+ { id: "autocompleteMaxVisible", label: "Autocomplete rows", fallback: "5" },
97
+ { id: "showHardwareCursor", label: "Hardware cursor", fallback: "on" },
98
+ { id: "quietStartup", label: "Quiet startup", fallback: "off" },
99
+ { id: "doubleEscapeAction", label: "Double Escape", fallback: "tree" },
100
+ { id: "treeFilterMode", label: "Tree filter", fallback: "default" },
101
+ { id: "terminal.clearOnShrink", label: "Clear on shrink", fallback: "off" },
102
+ { id: "terminal.showTerminalProgress", label: "Terminal progress", fallback: "on" },
103
+ ] as const;
104
+ export function defaultSettings(): Settings { return { theme: "dark", thinking: "high", statusBar: true }; }
52
105
  export function mergeSettings(layers: SettingsLayers): Settings {
53
- return {
54
- ...layers.builtin,
55
- ...layers.user,
56
- ...layers.project,
57
- ...layers.cli,
106
+ const { defaultProjectTrust: _projectCannotGrantTrust, ...project } = layers.project ?? {};
107
+ const all = [layers.builtin, layers.user ?? {}, project, layers.cli ?? {}];
108
+ const merged: Settings = Object.assign({}, ...all);
109
+ return { ...merged,
110
+ ...(all.some(s => s.terminal !== undefined) ? { terminal: Object.assign({}, ...all.map(s => s.terminal ?? {})) } : {}),
111
+ ...(all.some(s => s.images !== undefined) ? { images: Object.assign({}, ...all.map(s => s.images ?? {})) } : {}),
112
+ ...(all.some(s => s.markdown !== undefined) ? { markdown: Object.assign({}, ...all.map(s => s.markdown ?? {})) } : {}),
113
+ ...(all.some(s => s.modelThinkingLevels !== undefined) ? { modelThinkingLevels: Object.assign({}, ...all.map(s => s.modelThinkingLevels ?? {})) } : {}),
58
114
  };
59
115
  }
60
-
61
- /**
62
- * Only keys present on `explicit`. Passing the merged object here would
63
- * persist defaults and pin old users to them.
64
- */
65
116
  export function settingsToWrite(explicit: Partial<Settings>): Record<string, unknown> {
66
- const out: Record<string, unknown> = {};
67
- if (explicit.theme !== undefined) out["theme"] = explicit.theme;
68
- if (explicit.thinking !== undefined) out["thinking"] = explicit.thinking;
69
- if (explicit.statusBar !== undefined) out["statusBar"] = explicit.statusBar;
70
- if (explicit.defaultProvider !== undefined) out["defaultProvider"] = explicit.defaultProvider;
71
- if (explicit.defaultModel !== undefined) out["defaultModel"] = explicit.defaultModel;
72
- if (explicit.defaultThinking !== undefined) out["defaultThinking"] = explicit.defaultThinking;
73
- if (explicit.scopedModels !== undefined) out["scopedModels"] = explicit.scopedModels;
74
- return out;
117
+ return Object.fromEntries(SETTINGS_KEYS.filter(key => explicit[key] !== undefined).map(key => [key, explicit[key]]));
75
118
  }
76
-
119
+ export function explicitSettings(raw: unknown, parsed: Settings): Partial<Settings> {
120
+ if (!isRecord(raw)) return {};
121
+ return Object.fromEntries(SETTINGS_KEYS.filter(key => (key in raw || Object.entries(aliases).some(([alias, canonical]) => canonical === key && alias in raw)) && parsed[key] !== undefined).map(key => [key, parsed[key]]));
122
+ }
123
+ const aliases: Readonly<Record<string, string>> = { defaultThinkingLevel: "defaultThinking", enabledModels: "scopedModels" };
77
124
  export function parseSettings(json: unknown): SettingsParse {
78
- if (json === null || json === undefined) {
79
- return { settings: {}, diagnostics: [] };
80
- }
81
- if (typeof json !== "object" || Array.isArray(json)) {
82
- return { settings: {}, diagnostics: ["settings is not an object"] };
83
- }
84
- const rec = json as { readonly [k: string]: unknown };
85
- const diagnostics: string[] = [];
86
- const settings: { [K in SettingsKey]?: Settings[K] } = {};
87
-
88
- for (const key of Object.keys(rec)) {
89
- if (!(SETTINGS_KEYS as readonly string[]).includes(key)) {
90
- diagnostics.push(`unknown setting: ${key}`);
91
- }
92
- }
93
-
94
- const theme = rec["theme"];
95
- if (theme !== undefined) {
96
- if (typeof theme === "string" && theme.length > 0) settings.theme = theme;
97
- else diagnostics.push("settings.theme must be a non-empty string");
125
+ if (json === null || json === undefined) return { settings: {}, diagnostics: [] };
126
+ if (!isRecord(json)) return { settings: {}, diagnostics: ["settings is not an object"] };
127
+ const out: Record<string, unknown> = {}, diagnostics: string[] = [];
128
+ for (const [rawKey, value] of Object.entries(json)) {
129
+ const key = aliases[rawKey] ?? rawKey;
130
+ if (rawKey !== key && key in json) continue;
131
+ if (value === undefined) continue;
132
+ if (key === "packages") {
133
+ if (Array.isArray(value) && value.every(packageDeclaration)) out[key] = value;
134
+ else diagnostics.push("settings.packages must contain source strings or package resource filters");
135
+ } else if (key === "modelThinkingLevels") {
136
+ if (!isRecord(value)) { diagnostics.push("settings.modelThinkingLevels must be an object"); continue; }
137
+ const mapped: Record<string, ThinkingLevel> = {};
138
+ for (const [id, level] of Object.entries(value)) {
139
+ const parsed = asThinkingLevel(level);
140
+ if (id.includes("/") && !id.startsWith("/") && !id.endsWith("/") && parsed) mapped[id] = parsed;
141
+ else diagnostics.push("settings.modelThinkingLevels requires provider/model identities and supported thinking names");
142
+ }
143
+ out[key] = mapped;
144
+ } else if (key === "terminal" || key === "images" || key === "markdown") {
145
+ if (!isRecord(value)) { diagnostics.push("settings." + key + " must be an object"); continue; }
146
+ const nested: Record<string, unknown> = {};
147
+ for (const [subkey, subvalue] of Object.entries(value)) validate(key + "." + subkey, subvalue, nested, diagnostics, subkey);
148
+ out[key] = nested;
149
+ } else validate(key, value, out, diagnostics, key);
98
150
  }
99
-
100
- const thinking = rec["thinking"];
101
- if (thinking !== undefined) {
102
- if (typeof thinking === "string" && THINKING_LEVELS.has(thinking)) settings.thinking = thinking;
103
- else diagnostics.push("settings.thinking must be low|medium|high|xhigh|max");
104
- }
105
-
106
- const statusBar = rec["statusBar"];
107
- if (statusBar !== undefined) {
108
- if (typeof statusBar === "boolean") settings.statusBar = statusBar;
109
- else diagnostics.push("settings.statusBar must be a boolean");
110
- }
111
-
112
- const defaultProvider = rec["defaultProvider"];
113
- if (defaultProvider !== undefined) {
114
- if (typeof defaultProvider === "string") settings.defaultProvider = defaultProvider;
115
- else diagnostics.push("settings.defaultProvider must be a string");
116
- }
117
-
118
- const defaultModel = rec["defaultModel"];
119
- if (defaultModel !== undefined) {
120
- if (typeof defaultModel === "string") settings.defaultModel = defaultModel;
121
- else diagnostics.push("settings.defaultModel must be a string");
122
- }
123
-
124
- const defaultThinking = rec["defaultThinking"];
125
- if (defaultThinking !== undefined) {
126
- if (typeof defaultThinking === "string" && THINKING_LEVELS.has(defaultThinking)) {
127
- settings.defaultThinking = defaultThinking;
128
- } else diagnostics.push("settings.defaultThinking must be low|medium|high|xhigh|max");
129
- }
130
-
131
- const scopedModels = rec["scopedModels"];
132
- if (scopedModels !== undefined) {
133
- const parsed = parseScopedModels(scopedModels);
134
- if (parsed === undefined) diagnostics.push("settings.scopedModels must be an array of strings");
135
- else settings.scopedModels = parsed;
136
- }
137
-
138
- return { settings, diagnostics };
151
+ return { settings: out as Settings, diagnostics };
139
152
  }
140
-
141
- function parseScopedModels(raw: unknown): readonly string[] | undefined {
142
- if (Array.isArray(raw) && raw.every((x) => typeof x === "string")) return raw as string[];
143
- if (typeof raw === "string") {
144
- return raw.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
145
- }
146
- return undefined;
153
+ function isRecord(value: unknown): value is Record<string, unknown> {
154
+ return value !== null && typeof value === "object" && !Array.isArray(value);
147
155
  }
148
-
149
- /** Keys that were actually present on the raw object, after validation. */
150
- export function explicitSettings(raw: unknown, parsed: Settings): Partial<Settings> {
151
- if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return {};
152
- const rec = raw as { readonly [k: string]: unknown };
153
- const out: {
154
- theme?: string;
155
- thinking?: string;
156
- statusBar?: boolean;
157
- defaultProvider?: string;
158
- defaultModel?: string;
159
- defaultThinking?: string;
160
- scopedModels?: readonly string[];
161
- } = {};
162
- if ("theme" in rec && parsed.theme !== undefined) out.theme = parsed.theme;
163
- if ("thinking" in rec && parsed.thinking !== undefined) out.thinking = parsed.thinking;
164
- if ("statusBar" in rec && parsed.statusBar !== undefined) out.statusBar = parsed.statusBar;
165
- if ("defaultProvider" in rec && parsed.defaultProvider !== undefined) {
166
- out.defaultProvider = parsed.defaultProvider;
167
- }
168
- if ("defaultModel" in rec && parsed.defaultModel !== undefined) out.defaultModel = parsed.defaultModel;
169
- if ("defaultThinking" in rec && parsed.defaultThinking !== undefined) {
170
- out.defaultThinking = parsed.defaultThinking;
171
- }
172
- if ("scopedModels" in rec && parsed.scopedModels !== undefined) out.scopedModels = parsed.scopedModels;
173
- return out;
156
+ function validate(key: string, raw: unknown, out: Record<string, unknown>, diagnostics: string[], field: string): void {
157
+ const rule = rules[key];
158
+ if (!rule) { diagnostics.push("unknown setting: " + key); return; }
159
+ const value = rule.kind === "strings" && typeof raw === "string" ? raw.split(",").map(v => v.trim()).filter(Boolean) : raw;
160
+ const valid = rule.kind === "string" ? typeof value === "string" && value.length > 0 :
161
+ rule.kind === "boolean" ? typeof value === "boolean" :
162
+ rule.kind === "strings" ? Array.isArray(value) && value.every(v => typeof v === "string") :
163
+ rule.kind === "enum" ? typeof value === "string" && rule.values.includes(value) :
164
+ rule.kind === "integer" && ((rule.auto && value === "auto") || (typeof value === "number" && Number.isSafeInteger(value) && value >= rule.min && value <= rule.max));
165
+ if (valid) out[field] = value;
166
+ else diagnostics.push("settings." + key + " must be " + (rule.kind === "enum" ? rule.values.join("|") : rule.kind === "integer" ? rule.min + ".." + rule.max + (rule.auto ? " or auto" : "") : rule.kind));
167
+ }
168
+ export function settingValues(key: string): string[] {
169
+ const rule = rules[key];
170
+ if (rule?.kind === "enum") return [...rule.values];
171
+ if (rule?.kind === "boolean") return ["on", "off"];
172
+ if (rule?.kind === "integer") return [...(rule.auto ? ["auto"] : []), ...Array.from({ length: rule.max - rule.min + 1 }, (_, i) => String(i + rule.min))];
173
+ return [];
174
+ }
175
+ export function settingValue(settings: Settings, path: string, fallback: string): string {
176
+ const [key, subkey] = path.split(".");
177
+ const object = settings[key as SettingsKey];
178
+ const raw = subkey && isRecord(object) ? (object as Record<string, unknown>)[subkey] : object;
179
+ return typeof raw === "boolean" ? raw ? "on" : "off" : raw === undefined ? fallback : String(raw);
180
+ }
181
+ export function settingPatch(field: string, text: string): SettingsParse {
182
+ const key = Object.keys(rules).find(k => k.toLowerCase() === field.toLowerCase());
183
+ if (!key) return { settings: {}, diagnostics: ["unknown setting: " + field] };
184
+ const rule = rules[key]!;
185
+ const value = rule.kind === "boolean" ? ["on", "true", "1"].includes(text) ? true : ["off", "false", "0"].includes(text) ? false : text :
186
+ rule.kind === "integer" && text !== "auto" ? Number(text) : text;
187
+ const [first, second] = key.split(".");
188
+ return parseSettings(second ? { [first!]: { [second]: value } } : { [key]: value });
174
189
  }
package/src/core/theme.ts CHANGED
@@ -36,7 +36,8 @@ export const THEME_TOKENS = [
36
36
 
37
37
  export type ThemeToken = (typeof THEME_TOKENS)[number];
38
38
 
39
- export type ThemeColors = { readonly [K in ThemeToken]: string };
39
+ export type ThemeColors = { readonly [K in ThemeToken]: string } & Readonly<Record<string, string>>;
40
+ const PI_THEME_TOKENS: readonly string[] = ["accent","border","borderAccent","borderMuted","success","error","warning","muted","dim","text","thinkingText","scrollbarTrack","scrollbarThumb","searchMatchText","userMessageText","customMessageText","customMessageLabel","toolTitle","toolOutput","mdHeading","mdLink","mdLinkUrl","mdCode","mdCodeBlock","mdCodeBlockBorder","mdQuote","mdQuoteBorder","mdHr","mdListBullet","toolDiffAdded","toolDiffRemoved","toolDiffContext","syntaxComment","syntaxKeyword","syntaxFunction","syntaxVariable","syntaxString","syntaxNumber","syntaxType","syntaxOperator","syntaxPunctuation","thinkingOff","thinkingMinimal","thinkingLow","thinkingMedium","thinkingHigh","thinkingXhigh","thinkingMax","bashMode","selectedBg","searchMatchBg","userMessageBg","customMessageBg","toolPendingBg","toolSuccessBg","toolErrorBg"];
40
41
 
41
42
  export interface Theme {
42
43
  readonly name: string;
@@ -135,20 +136,28 @@ export function parseTheme(json: unknown): ThemeParse {
135
136
  })();
136
137
 
137
138
  for (const key of Object.keys(src)) {
138
- if (!isThemeToken(key)) diagnostics.push(`unknown token: ${key}`);
139
+ if (!isThemeToken(key) && !PI_THEME_TOKENS.includes(key)) diagnostics.push(`unknown token: ${key}`);
139
140
  }
140
141
 
141
- const colors: { [K in ThemeToken]: string } = { ...fallback.colors };
142
- for (const token of THEME_TOKENS) {
142
+ const colors: { [K in ThemeToken]: string } & Record<string, string> = { ...fallback.colors };
143
+ const vars = typeof rec["vars"] === "object" && rec["vars"] !== null ? rec["vars"] as Record<string, unknown> : {};
144
+ const resolve = (value: unknown, seen = new Set<string>()): unknown => {
145
+ if (typeof value !== "string" || !(value in vars)) return value;
146
+ if (seen.has(value)) return null;
147
+ return resolve(vars[value], new Set([...seen, value]));
148
+ };
149
+ for (const token of new Set<string>([...THEME_TOKENS, ...PI_THEME_TOKENS])) {
143
150
  if (!(token in src)) continue;
144
- const parsed = parseColor(src[token]);
151
+ const parsed = parseColor(resolve(src[token]));
145
152
  if (parsed === null) {
146
153
  diagnostics.push(`invalid colour for ${token}; using built-in`);
147
154
  continue;
148
155
  }
149
- colors[token] = parsed;
156
+ colors[token] = token.endsWith("Bg") ? parsed.replace(/^38;/, "48;") : parsed;
150
157
  }
151
158
 
159
+ if (!("status" in src) && "dim" in colors) colors.status = colors["dim"]!;
160
+ if (!("prompt" in src)) colors.prompt = colors.text;
152
161
  return { theme: { name, colors }, diagnostics };
153
162
  }
154
163
 
@@ -0,0 +1,26 @@
1
+ /** Ported from Pi (MIT), ai/models.ts at 02eed88. Capability policy, independent of provider I/O. */
2
+ export const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
3
+ export type ThinkingLevel = (typeof THINKING_LEVELS)[number];
4
+ export interface ThinkingModel {
5
+ readonly reasoning: boolean;
6
+ readonly thinkingLevelMap?: Partial<Record<ThinkingLevel, string | null>>;
7
+ }
8
+ export function asThinkingLevel(value: unknown): ThinkingLevel | undefined {
9
+ return typeof value === "string" && (THINKING_LEVELS as readonly string[]).includes(value) ? value as ThinkingLevel : undefined;
10
+ }
11
+ export function supportedThinkingLevels(model: ThinkingModel): ThinkingLevel[] {
12
+ if (!model.reasoning) return ["off"];
13
+ return THINKING_LEVELS.filter(level => {
14
+ const mapped = model.thinkingLevelMap?.[level];
15
+ if (mapped === null) return false;
16
+ return level === "xhigh" || level === "max" ? mapped !== undefined : true;
17
+ });
18
+ }
19
+ export function clampThinkingLevel(model: ThinkingModel, requested: ThinkingLevel): ThinkingLevel {
20
+ const available = supportedThinkingLevels(model);
21
+ const index = THINKING_LEVELS.indexOf(requested);
22
+ for (const level of [...THINKING_LEVELS.slice(index), ...THINKING_LEVELS.slice(0, index).reverse()]) {
23
+ if (available.includes(level)) return level;
24
+ }
25
+ return "off";
26
+ }
@@ -10,6 +10,7 @@
10
10
  */
11
11
 
12
12
  import type { Message } from "./project.ts";
13
+ import { estimateImageTokens } from "./images.ts";
13
14
 
14
15
  export interface TokenCounter {
15
16
  readonly kind: "estimate" | "exact";
@@ -73,6 +74,7 @@ export const estimateTokens: TokenCounter = {
73
74
  else if (b.type === "thinking") tokens += estimateText(JSON.stringify(b.raw));
74
75
  else if (b.type === "tool_use") tokens += estimateText(b.name) + estimateText(JSON.stringify(b.input));
75
76
  else if (b.type === "tool_result") tokens += estimateText(b.content);
77
+ else if (b.type === "image") tokens += estimateImageTokens(b.image);
76
78
  }
77
79
  // Per-message framing overhead, roughly.
78
80
  tokens += 8 / 3.6;