@timurproko/a1 0.1.8-dev.157 → 0.1.8-dev.182

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 (64) hide show
  1. package/README.md +32 -0
  2. package/bin/ui.js +17 -2
  3. package/dist/composition/owned-ui.d.ts +4 -1
  4. package/dist/composition/owned-ui.js +8 -5
  5. package/dist/contracts/agent-engine/capability-ports.d.ts +2 -2
  6. package/dist/contracts/agent-engine/domain-validation.js +22 -4
  7. package/dist/contracts/agent-engine/domain.d.ts +17 -0
  8. package/dist/contracts/owned-ui/model.d.ts +8 -0
  9. package/dist/contracts/owned-ui/validation.js +11 -0
  10. package/dist/features/owned-ui/index.d.ts +1 -0
  11. package/dist/features/owned-ui/index.js +1 -0
  12. package/dist/features/owned-ui/project-trust-prompt.d.ts +20 -0
  13. package/dist/features/owned-ui/project-trust-prompt.js +32 -0
  14. package/dist/features/owned-ui/settings-app.js +37 -9
  15. package/dist/integrations/pi/components/shell-components.d.ts +1 -0
  16. package/dist/integrations/pi/components/shell-components.js +1 -0
  17. package/dist/integrations/pi/components/shell-editor-autocomplete.js +10 -0
  18. package/dist/integrations/pi/components/shell-footer-status.js +14 -9
  19. package/dist/integrations/pi/components/shell-presenters-info.d.ts +36 -0
  20. package/dist/integrations/pi/components/shell-presenters-info.js +78 -0
  21. package/dist/integrations/pi/components/shell-presenters-transcript.d.ts +3 -36
  22. package/dist/integrations/pi/components/shell-presenters-transcript.js +88 -128
  23. package/dist/integrations/pi/components/shell-shared-facade.d.ts +11 -1
  24. package/dist/integrations/pi/engine/adapter.d.ts +15 -4
  25. package/dist/integrations/pi/engine/adapter.js +185 -37
  26. package/dist/integrations/pi/engine/http-dispatcher.d.ts +4 -0
  27. package/dist/integrations/pi/engine/http-dispatcher.js +25 -0
  28. package/dist/integrations/pi/engine/index.d.ts +3 -0
  29. package/dist/integrations/pi/engine/index.js +3 -0
  30. package/dist/integrations/pi/engine/project-trust-preflight.d.ts +22 -0
  31. package/dist/integrations/pi/engine/project-trust-preflight.js +50 -0
  32. package/dist/integrations/pi/engine/runtime-integration.d.ts +16 -1
  33. package/dist/integrations/pi/engine/runtime-integration.js +34 -4
  34. package/dist/integrations/pi/engine/settings-effects.d.ts +55 -0
  35. package/dist/integrations/pi/engine/settings-effects.js +229 -0
  36. package/dist/integrations/pi/engine/settings-integration.d.ts +14 -26
  37. package/dist/integrations/pi/engine/settings-integration.js +102 -108
  38. package/dist/integrations/pi/engine/workflow-controllers.d.ts +1 -1
  39. package/dist/integrations/pi/session-ui/clipboard-image.d.ts +11 -0
  40. package/dist/integrations/pi/session-ui/clipboard-image.js +29 -0
  41. package/dist/integrations/pi/session-ui/prompt-chips.js +5 -1
  42. package/dist/integrations/pi/session-ui/session-shell-root.d.ts +9 -2
  43. package/dist/integrations/pi/session-ui/session-shell-root.js +83 -9
  44. package/dist/integrations/pi/session-ui/session-shell.d.ts +2 -0
  45. package/dist/integrations/pi/session-ui/session-shell.js +151 -15
  46. package/dist/integrations/pi/session-ui/session-viewport-controller.js +34 -2
  47. package/dist/integrations/pi/session-ui/system-clipboard.d.ts +9 -0
  48. package/dist/integrations/pi/session-ui/system-clipboard.js +34 -5
  49. package/dist/integrations/pi/tui-runtime/adapter.d.ts +4 -0
  50. package/dist/integrations/pi/tui-runtime/adapter.js +28 -0
  51. package/dist/native/darwin-arm64/manifest.json +1 -1
  52. package/dist/native/linux-x64/manifest.json +1 -1
  53. package/dist/native/win32-x64/manifest.json +2 -2
  54. package/dist/native/win32-x64/process-guardian.exe +0 -0
  55. package/dist/ui/components/spans.d.ts +2 -0
  56. package/dist/ui/components/spans.js +25 -0
  57. package/dist/ui/settings/sections.d.ts +8 -19
  58. package/dist/ui/settings/sections.js +12 -4
  59. package/dist/ui/settings/session.d.ts +8 -18
  60. package/dist/ui/settings/session.js +59 -52
  61. package/docs/architecture/project-structure.md +14 -0
  62. package/docs/ci-release-runbook.md +69 -4
  63. package/docs/repository-governance-live-acceptance.md +77 -0
  64. package/package.json +5 -2
package/README.md CHANGED
@@ -78,6 +78,38 @@ npm run test:fast # typecheck + fast suite (alias: npm test)
78
78
  npm run test:full # complete non-physical suite
79
79
  ```
80
80
 
81
+ Create every task worktree at `{working-dir}/.worktrees/<task-id>`, where
82
+ `{working-dir}` is the session's initial working directory. For working directory
83
+ `D:/Git/a1`, use `D:/Git/a1/.worktrees/<task-id>`—never a sibling such as
84
+ `D:/Git/a1-<task-id>`. The primary worktree remains on `develop` for integration only.
85
+
86
+ ## Pull request integration
87
+
88
+ Pull requests whose complete diff is only under `openspec/**`, under `docs/**`,
89
+ exactly the root `README.md`, or a combination of those paths are automatically
90
+ squash-merged after `Development validation required` succeeds. The automation
91
+ reads the complete GitHub changed-file list, including both sides of renames, and
92
+ runs only for trusted branches in this repository.
93
+
94
+ Any other path makes the pull request code/operational. That includes source, tests,
95
+ scripts, workflows, configuration, generated baselines, arbitrary root Markdown,
96
+ and a mixed documentation-plus-code change. Those pull requests remain open after
97
+ CI for local maintainer validation and explicit manual merge; automation disables
98
+ auto-merge if it was armed. Documentation remains exempt from product builds and
99
+ tests, but docs-sensitive generated governance and strict OpenSpec consistency are
100
+ checked before integration.
101
+
102
+ After any same-repository pull request into `develop` merges, trusted automation
103
+ reconciles its remote topic branch. Human merges use the close-event workflow;
104
+ documentation merges authored by `GITHUB_TOKEN` use a synchronous fallback because
105
+ GitHub suppresses recursive workflow events. Both delete only an unprotected live
106
+ ref that still equals the pull request's exact merged head SHA. Fork, advanced,
107
+ reserved, protected, malformed, and unmerged refs are preserved and reported.
108
+
109
+ Specification approval and implementation remain separate pull requests. An
110
+ implementation starts from updated `origin/develop` only after its specification
111
+ has merged and implementation was explicitly requested.
112
+
81
113
  ## Release
82
114
 
83
115
  Two channels, both published by CI from the exact bytes it validated — never from
package/bin/ui.js CHANGED
@@ -12,13 +12,28 @@ assertSinglePiTuiModuleAtLaunch(fileURLToPath(new URL("..", import.meta.url)), m
12
12
 
13
13
  const { runSelectedInteractiveRuntime } = await import("../dist/features/launch/index.js");
14
14
 
15
+ const launchArgs = process.argv.slice(2);
16
+ let sessionPath;
17
+ if (launchArgs.length > 0) {
18
+ if (launchArgs.length !== 2 || launchArgs[0] !== "--session" || launchArgs[1].trim().length === 0) {
19
+ throw new Error("Usage: a1 [--session <session-file>]");
20
+ }
21
+ sessionPath = launchArgs[1];
22
+ }
23
+
15
24
  runSelectedInteractiveRuntime(process.env.A1_LAUNCH_PROFILE ?? "a1", {
16
25
  ownedUi: async (profileId, ownedSurfaces) => {
17
- const [{ runOwnedUi }, { composeOwnedUi }] = await Promise.all([
26
+ const [{ createConsoleProjectTrustPrompt, runOwnedUi }, { composeOwnedUi }] = await Promise.all([
18
27
  import("../dist/features/owned-ui/index.js"),
19
28
  import("../dist/composition/index.js"),
20
29
  ]);
21
- const { application, settings } = await composeOwnedUi({ cwd: process.cwd(), profileId, ownedSurfaces });
30
+ const { application, settings } = await composeOwnedUi({
31
+ cwd: process.cwd(),
32
+ profileId,
33
+ ownedSurfaces,
34
+ projectTrustPrompt: createConsoleProjectTrustPrompt(),
35
+ ...(sessionPath === undefined ? {} : { sessionPath }),
36
+ });
22
37
  return await runOwnedUi({ application, ...(settings === null ? {} : { settings }) });
23
38
  },
24
39
  }).then(
@@ -1,10 +1,12 @@
1
- import { type PiEngineAdapter } from "../integrations/pi/engine/index.js";
1
+ import { type PiEngineAdapter, type PiProjectTrustPreflightPrompt } from "../integrations/pi/engine/index.js";
2
2
  import { OwnedUiSettingsSession } from "../ui/settings/index.js";
3
3
  import type { OwnedUiApplicationPort, PresentationTerminalPort } from "../contracts/presentation/index.js";
4
4
  export interface OwnedUiCompositionOptions {
5
5
  readonly cwd?: string;
6
6
  readonly terminal?: PresentationTerminalPort;
7
7
  readonly createPiAdapter?: () => Promise<PiEngineAdapter>;
8
+ /** Exact persisted session selected by the narrow `--session` launch form. */
9
+ readonly sessionPath?: string;
8
10
  /**
9
11
  * A1 profile whose settings this session reads and writes. Omitted keeps the
10
12
  * session settings-free, which is what the pinned comparison paths use.
@@ -15,6 +17,7 @@ export interface OwnedUiCompositionOptions {
15
17
  * use the same composition with those surfaces withheld.
16
18
  */
17
19
  readonly ownedSurfaces?: "on" | "off";
20
+ readonly projectTrustPrompt?: PiProjectTrustPreflightPrompt;
18
21
  }
19
22
  export interface OwnedUiComposition {
20
23
  readonly application: OwnedUiApplicationPort;
@@ -1,6 +1,6 @@
1
1
  import { resolveProductPaths } from "../foundation/lifecycle/index.js";
2
2
  import { applyConfiguredPiTheme, getAvailablePiThemes } from "../integrations/pi/components/index.js";
3
- import { createPiEngineAdapter } from "../integrations/pi/engine/index.js";
3
+ import { createPiEngineAdapter, } from "../integrations/pi/engine/index.js";
4
4
  import { OwnedUiSessionShell } from "../integrations/pi/session-ui/index.js";
5
5
  import { OwnedUiSettingsSession, OwnedUiSettingsStore } from "../ui/settings/index.js";
6
6
  import { createPiTerminalBridge } from "../integrations/pi/tui-runtime/index.js";
@@ -12,16 +12,19 @@ export async function composeOwnedUi(options = {}) {
12
12
  const cwd = options.cwd ?? process.cwd();
13
13
  const adapter = options.createPiAdapter
14
14
  ? await options.createPiAdapter()
15
- : await createPiEngineAdapter({ cwd, availableThemes: () => getAvailablePiThemes().map(theme => theme.name) });
15
+ : await createPiEngineAdapter({
16
+ cwd,
17
+ availableThemes: () => getAvailablePiThemes().map(theme => theme.name),
18
+ settingsProductMode: options.ownedSurfaces === "off" ? "comparison" : "bare",
19
+ ...(options.sessionPath === undefined ? {} : { sessionPath: options.sessionPath }),
20
+ ...(options.projectTrustPrompt === undefined ? {} : { projectTrustPrompt: options.projectTrustPrompt }),
21
+ });
16
22
  const ownedSurfaces = options.ownedSurfaces !== "off";
17
23
  const settings = options.profileId === undefined
18
24
  ? null
19
25
  : new OwnedUiSettingsSession({
20
26
  store: new OwnedUiSettingsStore({ configDir: resolveProductPaths().configDir, profileId: options.profileId }),
21
27
  agentProvider: () => adapter.settingsPort(),
22
- ...(ownedSurfaces ? {
23
- hiddenAgentSettingIds: ["tuiMode", "theme", "fullscreenScrollbar", "quietStartup"],
24
- } : {}),
25
28
  });
26
29
  // Bare A1 intentionally ships one visual target while its UI is being completed:
27
30
  // dark, regardless of terminal detection or a previously stored Pi theme. The
@@ -1,4 +1,4 @@
1
- import type { AgentJsonValue, AgentModelDescriptor, AgentResourceDescriptor, AgentSettingDescriptor } from "./domain.js";
1
+ import type { AgentJsonValue, AgentModelDescriptor, AgentSettingChangeOutcome, AgentResourceDescriptor, AgentSettingDescriptor } from "./domain.js";
2
2
  export interface AgentModelPort {
3
3
  readonly capabilities: {
4
4
  readonly selection: boolean;
@@ -26,7 +26,7 @@ export interface AgentSettingsPort {
26
26
  };
27
27
  listSettings(): Promise<readonly AgentSettingDescriptor[]>;
28
28
  readSetting(key: string): Promise<AgentJsonValue | undefined>;
29
- writeSetting?(key: string, value: AgentJsonValue): Promise<void>;
29
+ writeSetting?(key: string, value: AgentJsonValue): Promise<AgentSettingChangeOutcome>;
30
30
  flush?(): Promise<void>;
31
31
  }
32
32
  export interface AgentResourcesPort {
@@ -52,10 +52,28 @@ export function assertAgentUsage(value) { for (const amount of [value.inputToken
52
52
  nonNegative(amount, "usage token count"); if (value.cost !== null && (!(typeof value.cost === "number") || !Number.isFinite(value.cost) || value.cost < 0))
53
53
  throw new TypeError("usage cost is invalid"); }
54
54
  export function assertAgentModelDescriptor(value) { id(value.providerId, "model provider id"); id(value.modelId, "model id"); text(value.displayName, "model display name"); nonNegative(value.contextWindow, "model context window"); unique(value.thinkingLevels, undefined, "model thinking levels"); }
55
- export function assertAgentSettingDescriptor(value) { id(value.key, "setting key"); if (!["boolean", "number", "string", "enum", "json"].includes(value.valueType) || typeof value.writable !== "boolean")
56
- throw new TypeError("setting descriptor is invalid"); if (value.choices)
57
- for (const choice of value.choices)
58
- json(choice, "setting choice"); }
55
+ export function assertAgentSettingDescriptor(value) {
56
+ id(value.key, "setting key");
57
+ if (!["boolean", "number", "string", "enum", "json"].includes(value.valueType)
58
+ || typeof value.writable !== "boolean"
59
+ || !["live", "next-session", "next-start", "current-exit"].includes(value.application)
60
+ || !["agent", "shell", "terminal", "startup", "shutdown", "installation"].includes(value.owner)
61
+ || typeof value.available !== "boolean"
62
+ || !(value.limitationReason === null || typeof value.limitationReason === "string")) {
63
+ throw new TypeError("setting descriptor is invalid");
64
+ }
65
+ json(value.storedValue, "stored setting value");
66
+ json(value.effectiveValue, "effective setting value");
67
+ if ((value.available && value.limitationReason !== null)
68
+ || (!value.available && (value.limitationReason === null || value.limitationReason.length === 0))) {
69
+ throw new TypeError("setting descriptor availability is contradictory");
70
+ }
71
+ if (value.writable && !value.available)
72
+ throw new TypeError("unavailable setting cannot be writable");
73
+ if (value.choices)
74
+ for (const choice of value.choices)
75
+ json(choice, "setting choice");
76
+ }
59
77
  export function assertAgentResourceDescriptor(value) { id(value.id, "resource id"); if (!["command", "prompt", "skill", "extension", "other"].includes(value.kind))
60
78
  throw new TypeError("resource kind is invalid"); text(value.label, "resource label"); json(value.metadata, "resource metadata"); }
61
79
  export function assertAgentThemeDescriptor(value) { id(value.id, "theme id"); text(value.label, "theme label"); if (!value.tokens || typeof value.tokens !== "object" || Object.values(value.tokens).some(token => typeof token !== "string"))
@@ -48,10 +48,27 @@ export interface AgentModelDescriptor {
48
48
  readonly contextWindow: number;
49
49
  readonly thinkingLevels: readonly string[];
50
50
  }
51
+ export type AgentSettingApplicationBoundary = "live" | "next-session" | "next-start" | "current-exit";
52
+ export type AgentSettingOwner = "agent" | "shell" | "terminal" | "startup" | "shutdown" | "installation";
53
+ export interface AgentSettingChangeOutcome {
54
+ readonly status: "applied" | "deferred" | "unavailable" | "failed";
55
+ readonly application: AgentSettingApplicationBoundary;
56
+ readonly storedValue: AgentJsonValue;
57
+ readonly effectiveValue: AgentJsonValue;
58
+ readonly failure: string | null;
59
+ readonly limitationReason: string | null;
60
+ }
51
61
  export interface AgentSettingDescriptor {
52
62
  readonly key: string;
53
63
  readonly valueType: "boolean" | "number" | "string" | "enum" | "json";
64
+ /** A setting is writable only when its declared owner/effect is available. */
54
65
  readonly writable: boolean;
66
+ readonly application: AgentSettingApplicationBoundary;
67
+ readonly owner: AgentSettingOwner;
68
+ readonly available: boolean;
69
+ readonly limitationReason: string | null;
70
+ readonly storedValue: AgentJsonValue;
71
+ readonly effectiveValue: AgentJsonValue;
55
72
  readonly choices?: readonly AgentJsonValue[];
56
73
  /** Label the engine shows for this setting, when it has one. */
57
74
  readonly label?: string;
@@ -91,6 +91,12 @@ export interface OwnedUiOverlay {
91
91
  readonly modal: boolean;
92
92
  readonly payload: unknown;
93
93
  }
94
+ export interface OwnedUiTranscriptImageReference {
95
+ readonly assetId: OwnedUiEntityId;
96
+ readonly mimeType: string;
97
+ readonly byteLength: number;
98
+ readonly source: "user" | "tool-result";
99
+ }
94
100
  export interface OwnedUiTranscriptBlock {
95
101
  readonly id: OwnedUiEntityId;
96
102
  readonly kind: OwnedUiTranscriptBlockKind;
@@ -99,6 +105,8 @@ export interface OwnedUiTranscriptBlock {
99
105
  readonly title: string | null;
100
106
  readonly text: string;
101
107
  readonly payload: unknown;
108
+ /** Bounded opaque references; image bytes remain in the session-scoped engine asset store. */
109
+ readonly imageReferences?: readonly OwnedUiTranscriptImageReference[];
102
110
  }
103
111
  export type OwnedUiSlotId = "theme" | "transcript-block" | "tool-card" | "editor" | "status" | "command" | "selector" | "dialog" | "overlay" | "layout";
104
112
  export interface OwnedUiCustomization {
@@ -14,6 +14,7 @@ const MAX_QUEUE = 32;
14
14
  const MAX_BADGES = 32;
15
15
  const MAX_STATUS_DIAGNOSTICS = 32;
16
16
  const MAX_ACTIVE_COMMANDS = 64;
17
+ const IMAGE_REFERENCE_SOURCES = new Set(["user", "tool-result"]);
17
18
  const BLOCK_KINDS = new Set([
18
19
  "user",
19
20
  "assistant",
@@ -225,6 +226,16 @@ export function assertOwnedUiTranscriptBlock(block) {
225
226
  assertOptionalText(block.title, "owned-UI transcript block title", MAX_LABEL_LENGTH);
226
227
  assertPossiblyEmptyText(block.text, "owned-UI transcript block text", MAX_TEXT_BYTES);
227
228
  assertJsonValue(block.payload, "owned-UI transcript block payload", MAX_PAYLOAD_BYTES);
229
+ if (block.imageReferences !== undefined) {
230
+ assertCollection(block.imageReferences, "owned-UI transcript image references", 16);
231
+ for (const reference of block.imageReferences) {
232
+ assertId(reference.assetId, "owned-UI transcript image asset id");
233
+ if (!/^image\/[a-z0-9.+-]+$/i.test(reference.mimeType))
234
+ throw new TypeError("owned-UI transcript image MIME type is invalid");
235
+ assertIntegerInRange(reference.byteLength, 1, 20 * 1024 * 1024, "owned-UI transcript image byte length");
236
+ assertEnum(reference.source, IMAGE_REFERENCE_SOURCES, "owned-UI transcript image source");
237
+ }
238
+ }
228
239
  }
229
240
  export function assertOwnedUiEditorState(editor) {
230
241
  assertPossiblyEmptyText(editor.text, "owned-UI editor text", MAX_TEXT_BYTES);
@@ -1,4 +1,5 @@
1
1
  export * from "./customization.js";
2
2
  export * from "./diagnostics.js";
3
3
  export * from "./run.js";
4
+ export * from "./project-trust-prompt.js";
4
5
  export * from "./settings-app.js";
@@ -1,4 +1,5 @@
1
1
  export * from "./customization.js";
2
2
  export * from "./diagnostics.js";
3
3
  export * from "./run.js";
4
+ export * from "./project-trust-prompt.js";
4
5
  export * from "./settings-app.js";
@@ -0,0 +1,20 @@
1
+ import type { Readable, Writable } from "node:stream";
2
+ export interface OwnedProjectTrustPromptRequest {
3
+ readonly cwd: string;
4
+ readonly defaultDecision: "ask" | "always" | "never";
5
+ }
6
+ export type OwnedProjectTrustPrompt = (request: OwnedProjectTrustPromptRequest) => Promise<boolean | null>;
7
+ export interface ConsoleProjectTrustPromptOptions {
8
+ readonly input?: Readable & {
9
+ readonly isTTY?: boolean;
10
+ };
11
+ readonly output?: Writable & {
12
+ readonly isTTY?: boolean;
13
+ };
14
+ }
15
+ /**
16
+ * Minimal pre-session surface. It depends only on the parent terminal and fixed
17
+ * A1 wording, so no project setting, theme, extension, prompt, or skill can
18
+ * execute before the decision.
19
+ */
20
+ export declare function createConsoleProjectTrustPrompt(options?: ConsoleProjectTrustPromptOptions): OwnedProjectTrustPrompt;
@@ -0,0 +1,32 @@
1
+ import { createInterface } from "node:readline/promises";
2
+ /**
3
+ * Minimal pre-session surface. It depends only on the parent terminal and fixed
4
+ * A1 wording, so no project setting, theme, extension, prompt, or skill can
5
+ * execute before the decision.
6
+ */
7
+ export function createConsoleProjectTrustPrompt(options = {}) {
8
+ const input = options.input ?? process.stdin;
9
+ const output = options.output ?? process.stdout;
10
+ return async ({ cwd }) => {
11
+ if (input.isTTY !== true || output.isTTY !== true) {
12
+ throw new Error("an interactive terminal is unavailable");
13
+ }
14
+ const reader = createInterface({ input, output, terminal: true });
15
+ try {
16
+ output.write(`\nA1 found project-local settings or executable resources in:\n${cwd}\n`);
17
+ output.write("Trusting permits project settings, skills, prompts, packages, themes, and extensions to load.\n");
18
+ for (let attempt = 0; attempt < 3; attempt += 1) {
19
+ const answer = (await reader.question("Trust this project for this and future launches? [y/N] ")).trim().toLowerCase();
20
+ if (answer === "y" || answer === "yes")
21
+ return true;
22
+ if (answer === "" || answer === "n" || answer === "no")
23
+ return false;
24
+ output.write("Enter y or n.\n");
25
+ }
26
+ return null;
27
+ }
28
+ finally {
29
+ reader.close();
30
+ }
31
+ };
32
+ }
@@ -323,7 +323,7 @@ export class SettingsApp {
323
323
  if (typeof shown === "number")
324
324
  return;
325
325
  if (!entry.editable || entry.choices === null || entry.choices.length === 0) {
326
- this.#notice = `${labelOf(entry)} cannot be changed here`;
326
+ this.#notice = entry.limitationReason ?? `${labelOf(entry)} cannot be changed here`;
327
327
  return;
328
328
  }
329
329
  const current = shown === null ? 0 : Math.max(0, entry.choices.indexOf(shown));
@@ -456,7 +456,7 @@ export class SettingsApp {
456
456
  return;
457
457
  const entry = row.value;
458
458
  if (!entry.editable) {
459
- this.#notice = `${labelOf(entry)} cannot be changed here`;
459
+ this.#notice = entry.limitationReason ?? `${labelOf(entry)} cannot be changed here`;
460
460
  return;
461
461
  }
462
462
  const shown = this.#shownValue(entry);
@@ -470,7 +470,7 @@ export class SettingsApp {
470
470
  }
471
471
  const choices = entry.choices;
472
472
  if (choices === null || choices.length === 0) {
473
- this.#notice = `${labelOf(entry)} cannot be changed here`;
473
+ this.#notice = entry.limitationReason ?? `${labelOf(entry)} cannot be changed here`;
474
474
  return;
475
475
  }
476
476
  const current = shown === null ? -1 : choices.indexOf(shown);
@@ -486,15 +486,22 @@ export class SettingsApp {
486
486
  // steps from here rather than from a value the source has not caught up to.
487
487
  this.#pending.set(key, value);
488
488
  void this.#session.change(entry.backend, entry.id, value).then(outcome => {
489
- if (outcome.failure !== null) {
489
+ if (outcome.failure !== null || outcome.status === "failed") {
490
490
  this.#pending.delete(key);
491
- this.#notice = `Could not save ${labelOf(entry)}: ${outcome.failure}`;
491
+ this.#notice = `Could not save ${labelOf(entry)}: ${outcome.failure ?? "the effect failed"}`;
492
+ return;
493
+ }
494
+ if (outcome.status === "unavailable" || outcome.limitationReason !== null) {
495
+ this.#pending.delete(key);
496
+ this.#notice = outcome.limitationReason ?? `${labelOf(entry)} is unavailable`;
492
497
  return;
493
498
  }
494
499
  // A later press may have moved on; only the last request clears itself.
495
500
  if (this.#pending.get(key) === value)
496
501
  this.#pending.delete(key);
497
- this.#notice = outcome.pendingRestart ? `${labelOf(entry)} applies on the next start` : null;
502
+ this.#notice = outcome.status === "deferred" && outcome.application !== null
503
+ ? `${labelOf(entry)} is stored and applies ${applicationLabel(outcome.application)}`
504
+ : null;
498
505
  });
499
506
  }
500
507
  /** What the row shows: the value asked for if one is outstanding, else the source's. */
@@ -571,9 +578,13 @@ export class SettingsApp {
571
578
  /** What the list view needs to draw a setting: its words, and where it can go. */
572
579
  #viewRow(entry) {
573
580
  const shown = this.#shownValue(entry);
574
- const value = entry.structured
575
- ? CONFIGURE
576
- : shown === null ? describeRaw(entry.rawValue) : displayValue(shown);
581
+ const value = !entry.available
582
+ ? `unavailable — ${entry.limitationReason ?? "effect is unavailable"}`
583
+ : entry.structured
584
+ ? CONFIGURE
585
+ : shown === null
586
+ ? describeRaw(entry.rawValue)
587
+ : effectiveDisplay(entry, shown);
577
588
  const range = rangeOf(entry);
578
589
  return {
579
590
  key: `${entry.backend}:${entry.id}`,
@@ -648,6 +659,23 @@ function displayValue(value) {
648
659
  return value ? "yes" : "no";
649
660
  return String(value);
650
661
  }
662
+ function effectiveDisplay(entry, stored) {
663
+ const effective = entry.effectiveValue;
664
+ if (effective === stored)
665
+ return displayValue(stored);
666
+ const shownEffective = typeof effective === "string" || typeof effective === "number" || typeof effective === "boolean"
667
+ ? displayValue(effective)
668
+ : describeRaw(effective);
669
+ return `${displayValue(stored)} (effective ${shownEffective}; ${applicationLabel(entry.application)})`;
670
+ }
671
+ function applicationLabel(application) {
672
+ switch (application) {
673
+ case "live": return "live";
674
+ case "next-session": return "in the next session";
675
+ case "next-start": return "on the next start";
676
+ case "current-exit": return "when the current session exits";
677
+ }
678
+ }
651
679
  function describeRaw(value) {
652
680
  if (value === null || value === undefined)
653
681
  return "unset";
@@ -2,5 +2,6 @@ export * from "./shell-shared-facade.js";
2
2
  export * from "./shell-editor-autocomplete.js";
3
3
  export * from "./shell-selectors-dialogs.js";
4
4
  export * from "./shell-presenters-transcript.js";
5
+ export * from "./shell-presenters-info.js";
5
6
  export * from "./shell-footer-status.js";
6
7
  export * from "./shell-extension-ui.js";
@@ -2,5 +2,6 @@ export * from "./shell-shared-facade.js";
2
2
  export * from "./shell-editor-autocomplete.js";
3
3
  export * from "./shell-selectors-dialogs.js";
4
4
  export * from "./shell-presenters-transcript.js";
5
+ export * from "./shell-presenters-info.js";
5
6
  export * from "./shell-footer-status.js";
6
7
  export * from "./shell-extension-ui.js";
@@ -158,6 +158,16 @@ export function createPiShellEditor(options) {
158
158
  setSubmitHandler: handler => { submitHandler = handler; },
159
159
  setInterruptHandler: handler => { interruptHandler = handler; },
160
160
  setAutocompleteCommands,
161
+ setPaddingX(padding) {
162
+ editor.setPaddingX(padding);
163
+ editor.invalidate();
164
+ tui.requestRender();
165
+ },
166
+ setAutocompleteMaxVisible(maxVisible) {
167
+ editor.setAutocompleteMaxVisible(maxVisible);
168
+ editor.invalidate();
169
+ tui.requestRender();
170
+ },
161
171
  addAutocompleteProvider(factory) {
162
172
  if (typeof factory !== "function")
163
173
  throw new TypeError("extension autocomplete factory must be a function");
@@ -71,16 +71,17 @@ export function createPiShellStatus(view, runtime) {
71
71
  ensureTheme();
72
72
  const statusUi = createTuiFacade(runtime ?? { getColumns: () => 80, getRows: () => 24, requestRender() { } });
73
73
  let workingOverride;
74
- let component = statusComponent(view, statusUi, workingOverride);
75
- let signature = statusSignature(view, workingOverride);
74
+ let outputPad = PINNED_PI_LAYOUT.outputPad;
75
+ let component = statusComponent(view, statusUi, workingOverride, outputPad);
76
+ let signature = statusSignature(view, workingOverride, outputPad);
76
77
  const rebuild = () => {
77
- const nextSignature = statusSignature(view, workingOverride);
78
+ const nextSignature = statusSignature(view, workingOverride, outputPad);
78
79
  if (nextSignature === signature)
79
80
  return;
80
81
  if (component !== undefined && "dispose" in component && typeof component.dispose === "function")
81
82
  component.dispose();
82
83
  signature = nextSignature;
83
- component = statusComponent(view, statusUi, workingOverride);
84
+ component = statusComponent(view, statusUi, workingOverride, outputPad);
84
85
  };
85
86
  return {
86
87
  render: width => component?.render(width) ?? [],
@@ -93,6 +94,10 @@ export function createPiShellStatus(view, runtime) {
93
94
  workingOverride = message;
94
95
  rebuild();
95
96
  },
97
+ setOutputPad(padding) {
98
+ outputPad = padding;
99
+ rebuild();
100
+ },
96
101
  dispose() {
97
102
  if (component !== undefined && "dispose" in component && typeof component.dispose === "function")
98
103
  component.dispose();
@@ -121,19 +126,19 @@ export function createPiQueuedInputStatus(submissions, presentation = "pinned")
121
126
  },
122
127
  };
123
128
  }
124
- function statusComponent(view, ui, workingOverride) {
129
+ function statusComponent(view, ui, workingOverride, outputPad) {
125
130
  if (view.lifecycle === "busy")
126
131
  return new WorkingStatusIndicator(ui, workingOverride ?? view.status.workingMessage ?? "Working...");
127
132
  if (view.lifecycle === "failed") {
128
- return new Text(piTheme().fg("error", view.status.diagnostics.at(-1) ?? "Session failed"), PINNED_PI_LAYOUT.outputPad, 0);
133
+ return new Text(piTheme().fg("error", view.status.diagnostics.at(-1) ?? "Session failed"), outputPad, 0);
129
134
  }
130
135
  if (view.status.workingMessage !== null) {
131
- return new Text(piTheme().fg("muted", view.status.workingMessage), PINNED_PI_LAYOUT.outputPad, 0);
136
+ return new Text(piTheme().fg("muted", view.status.workingMessage), outputPad, 0);
132
137
  }
133
138
  return undefined;
134
139
  }
135
- function statusSignature(view, workingOverride) {
136
- return `${view.lifecycle}\u0000${workingOverride ?? ""}\u0000${view.status.workingMessage ?? ""}\u0000${view.status.diagnostics.at(-1) ?? ""}`;
140
+ function statusSignature(view, workingOverride, outputPad) {
141
+ return `${outputPad}\u0000${view.lifecycle}\u0000${workingOverride ?? ""}\u0000${view.status.workingMessage ?? ""}\u0000${view.status.diagnostics.at(-1) ?? ""}`;
137
142
  }
138
143
  function queuedInputText(submissions, presentation) {
139
144
  if (submissions.length === 0)
@@ -0,0 +1,36 @@
1
+ import { type PiShellComponentPort } from "./shell-shared-facade.js";
2
+ export interface PiShellSessionInfoPresentation {
3
+ readonly sessionName?: string;
4
+ readonly stats: {
5
+ readonly sessionFile?: string;
6
+ readonly sessionId: string;
7
+ readonly userMessages: number;
8
+ readonly assistantMessages: number;
9
+ readonly toolCalls: number;
10
+ readonly toolResults: number;
11
+ readonly totalMessages: number;
12
+ readonly tokens: {
13
+ readonly input: number;
14
+ readonly output: number;
15
+ readonly cacheRead: number;
16
+ readonly cacheWrite: number;
17
+ readonly total: number;
18
+ };
19
+ readonly cost: number;
20
+ };
21
+ readonly cacheWaste: {
22
+ readonly missedTokens: number;
23
+ readonly missedCost: number;
24
+ readonly missCount: number;
25
+ };
26
+ readonly usageBreakdown: readonly {
27
+ readonly key: string;
28
+ readonly cost: number;
29
+ readonly tokens: number;
30
+ }[];
31
+ }
32
+ export declare function renderPiShellStatusText(message: string, width: number, outputPad?: 0 | 1): readonly string[];
33
+ export declare function createPiShellSessionInfo(presentation: PiShellSessionInfoPresentation): PiShellComponentPort;
34
+ export declare function createPiShellCollapsedChangelog(): PiShellComponentPort;
35
+ export declare function createPiShellChangelog(markdown: string): PiShellComponentPort;
36
+ export declare function createPiShellHotkeys(): PiShellComponentPort;
@@ -0,0 +1,78 @@
1
+ import { DynamicBorder, getMarkdownTheme } from "@earendil-works/pi-coding-agent";
2
+ import { Container, Markdown, Spacer, Text } from "#pi-tui";
3
+ import { KeybindingsManager } from "./upstream/adjacent/core/keybindings.js";
4
+ import { PINNED_PI_LAYOUT, piTheme } from "./theme.js";
5
+ import { componentPort, ensureTheme, formatSessionTokens } from "./shell-shared-facade.js";
6
+ export function renderPiShellStatusText(message, width, outputPad = PINNED_PI_LAYOUT.outputPad) {
7
+ ensureTheme();
8
+ return new Text(piTheme().fg("dim", message), outputPad, 0).render(width);
9
+ }
10
+ export function createPiShellSessionInfo(presentation) {
11
+ ensureTheme();
12
+ const { stats, sessionName, cacheWaste, usageBreakdown } = presentation;
13
+ let info = `${piTheme().bold("Session Info")}\n\n`;
14
+ if (sessionName)
15
+ info += `${piTheme().fg("dim", "Name:")} ${sessionName}\n`;
16
+ info += `${piTheme().fg("dim", "File:")} ${stats.sessionFile ?? "In-memory"}\n${piTheme().fg("dim", "ID:")} ${stats.sessionId}\n\n`;
17
+ info += `${piTheme().bold("Messages")}\n${piTheme().fg("dim", "Total:")} ${stats.totalMessages}\n${piTheme().fg("dim", "User:")} ${stats.userMessages}\n`;
18
+ info += `${piTheme().fg("dim", "Assistant:")} ${stats.assistantMessages}\n${piTheme().fg("dim", "Tools:")} ${stats.toolCalls} calls, ${stats.toolResults} results\n\n`;
19
+ info += `${piTheme().bold("Tokens")}\n`;
20
+ const { input, cacheRead, cacheWrite } = stats.tokens;
21
+ const promptTokens = input + cacheRead + cacheWrite;
22
+ info += `${piTheme().fg("dim", "Input:")} ${promptTokens.toLocaleString()}\n`;
23
+ if (promptTokens > 0 && (cacheRead > 0 || cacheWrite > 0)) {
24
+ info += ` ${piTheme().fg("dim", "Cached:")} ${cacheRead.toLocaleString()} ${piTheme().fg("dim", `(${((cacheRead / promptTokens) * 100).toFixed(1)}%)`)}\n`;
25
+ const written = cacheWrite > 0 ? ` ${piTheme().fg("dim", `(${cacheWrite.toLocaleString()} written to cache)`)}` : "";
26
+ info += ` ${piTheme().fg("dim", "Uncached:")} ${(input + cacheWrite).toLocaleString()}${written}\n`;
27
+ }
28
+ info += `${piTheme().fg("dim", "Output:")} ${stats.tokens.output.toLocaleString()}\n${piTheme().fg("dim", "Total:")} ${stats.tokens.total.toLocaleString()}\n`;
29
+ if (stats.cost > 0 || cacheWaste.missedTokens > 0) {
30
+ info += `\n${piTheme().bold("Cost")}\n${piTheme().fg("dim", "Total:")} $${stats.cost.toFixed(3)}`;
31
+ if (usageBreakdown.length > 1)
32
+ for (const entry of usageBreakdown)
33
+ info += `\n ${piTheme().fg("dim", `${entry.key}:`)} $${entry.cost.toFixed(3)} ${piTheme().fg("dim", `(${formatSessionTokens(entry.tokens)} tokens)`)}`;
34
+ if (cacheWaste.missedTokens > 0) {
35
+ const detail = `${cacheWaste.missedTokens.toLocaleString()} tokens, ${cacheWaste.missCount === 1 ? "1 miss" : `${cacheWaste.missCount} misses`}`;
36
+ info += cacheWaste.missedCost >= 0.0001 ? `\n${piTheme().fg("dim", "Cache Re-billed:")} $${cacheWaste.missedCost.toFixed(3)} ${piTheme().fg("dim", `(${detail})`)}` : `\n${piTheme().fg("dim", "Cache Re-billed:")} ${detail}`;
37
+ }
38
+ }
39
+ const container = new Container();
40
+ container.addChild(new Spacer(1));
41
+ container.addChild(new Text(info, 1, 0));
42
+ return componentPort(container);
43
+ }
44
+ export function createPiShellCollapsedChangelog() {
45
+ ensureTheme();
46
+ const container = new Container();
47
+ container.addChild(new Spacer(1));
48
+ container.addChild(new DynamicBorder());
49
+ container.addChild(new Text(`${piTheme().bold(piTheme().fg("accent", "What's New"))}\n${piTheme().fg("muted", "Run /changelog to view the full release notes.")}`, 1, 0));
50
+ container.addChild(new DynamicBorder());
51
+ return componentPort(container);
52
+ }
53
+ export function createPiShellChangelog(markdown) {
54
+ ensureTheme();
55
+ const container = new Container();
56
+ container.addChild(new Spacer(1));
57
+ container.addChild(new DynamicBorder());
58
+ container.addChild(new Text(piTheme().bold(piTheme().fg("accent", "What's New")), 1, 0));
59
+ container.addChild(new Spacer(1));
60
+ container.addChild(new Markdown(markdown.trim() || "No changelog entries found.", 1, 1, getMarkdownTheme()));
61
+ container.addChild(new DynamicBorder());
62
+ return componentPort(container);
63
+ }
64
+ export function createPiShellHotkeys() {
65
+ ensureTheme();
66
+ const keys = new KeybindingsManager();
67
+ const display = (action) => keys.getKeys(action).map(key => key.split("+").map(part => part.charAt(0).toUpperCase() + part.slice(1)).join("+")).join("/");
68
+ const row = (actions, description) => `| ${actions.map(action => `\`${display(action)}\``).join(" / ")} | ${description} |`;
69
+ const markdown = ["**Navigation**", "| Key | Action |", "|-----|--------|", row(["tui.editor.cursorUp", "tui.editor.cursorDown", "tui.editor.cursorLeft", "tui.editor.cursorRight"], "Move cursor / browse history"), row(["tui.editor.cursorWordLeft", "tui.editor.cursorWordRight"], "Move by word"), row(["tui.editor.cursorLineStart"], "Start of line"), row(["tui.editor.cursorLineEnd"], "End of line"), row(["tui.editor.jumpForward"], "Jump forward to character"), row(["tui.editor.jumpBackward"], "Jump backward to character"), row(["tui.editor.pageUp", "tui.editor.pageDown"], "Scroll by page"), "", "**Editing**", "| Key | Action |", "|-----|--------|", row(["tui.input.submit"], "Send message"), row(["tui.input.newLine"], `New line${process.platform === "win32" ? " (Ctrl+Enter on Windows Terminal)" : ""}`), row(["tui.editor.deleteWordBackward"], "Delete word backwards"), row(["tui.editor.deleteWordForward"], "Delete word forwards"), row(["tui.editor.deleteToLineStart"], "Delete to start of line"), row(["tui.editor.deleteToLineEnd"], "Delete to end of line"), row(["tui.editor.yank"], "Paste the most-recently-deleted text"), row(["tui.editor.yankPop"], "Cycle through the deleted text after pasting"), row(["tui.editor.undo"], "Undo"), "", "**Other**", "| Key | Action |", "|-----|--------|", row(["tui.input.tab"], "Path completion / accept autocomplete"), row(["app.interrupt"], "Cancel autocomplete / abort streaming"), row(["app.clear"], "Clear editor (first) / exit (second)"), row(["app.exit"], "Exit (when editor is empty)"), row(["app.suspend"], "Suspend to background"), row(["app.thinking.cycle"], "Cycle thinking level"), row(["app.model.cycleForward", "app.model.cycleBackward"], "Cycle models"), row(["app.model.select"], "Open model selector"), row(["app.tools.expand"], "Toggle tool output expansion"), row(["app.thinking.toggle"], "Toggle thinking block visibility"), row(["app.editor.external"], "Edit message in external editor"), row(["app.message.copy"], "Copy last assistant message"), row(["app.message.followUp"], "Queue follow-up message"), row(["app.message.dequeue"], "Restore queued messages"), row(["app.clipboard.pasteImage"], "Paste image or text from clipboard"), "| `/` | Slash commands |", "| `!` | Run bash command |", "| `!!` | Run bash command (excluded from context) |"].join("\n");
70
+ const container = new Container();
71
+ container.addChild(new Spacer(1));
72
+ container.addChild(new DynamicBorder());
73
+ container.addChild(new Text(piTheme().bold(piTheme().fg("accent", "Keyboard Shortcuts")), 1, 0));
74
+ container.addChild(new Spacer(1));
75
+ container.addChild(new Markdown(markdown, 1, 1, getMarkdownTheme()));
76
+ container.addChild(new DynamicBorder());
77
+ return componentPort(container);
78
+ }