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

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 (59) hide show
  1. package/README.md +21 -9
  2. package/bin/cli.js +1 -0
  3. package/dist/cli/dispatch.d.ts +6 -0
  4. package/dist/cli/dispatch.js +107 -81
  5. package/dist/cli/version-stats.js +7 -1
  6. package/dist/composition/owned-ui.js +10 -7
  7. package/dist/composition/settings-route-host.d.ts +1 -1
  8. package/dist/composition/settings-route-host.js +1 -1
  9. package/dist/contracts/owned-ui/model.d.ts +8 -1
  10. package/dist/contracts/owned-ui/validation.js +11 -0
  11. package/dist/features/owned-ui/settings-app.js +2 -1
  12. package/dist/integrations/pi/components/owned-editor-ux.d.ts +55 -0
  13. package/dist/integrations/pi/components/owned-editor-ux.js +778 -0
  14. package/dist/integrations/pi/components/shell-editor-autocomplete.js +44 -6
  15. package/dist/integrations/pi/components/shell-shared-facade.d.ts +41 -7
  16. package/dist/integrations/pi/components/shell-shared-facade.js +3 -1
  17. package/dist/integrations/pi/components/upstream/adjacent/core/keybindings.d.ts +10 -2
  18. package/dist/integrations/pi/components/upstream/adjacent/core/keybindings.js +36 -3
  19. package/dist/integrations/pi/engine/adapter.js +5 -1
  20. package/dist/integrations/pi/engine/session-integration.d.ts +2 -1
  21. package/dist/integrations/pi/engine/session-integration.js +14 -3
  22. package/dist/integrations/pi/session-ui/prompt-chips.d.ts +23 -0
  23. package/dist/integrations/pi/session-ui/prompt-chips.js +242 -0
  24. package/dist/integrations/pi/session-ui/route-host.d.ts +5 -0
  25. package/dist/integrations/pi/{owned-ui → session-ui}/session-shell-root.d.ts +17 -3
  26. package/dist/integrations/pi/{owned-ui → session-ui}/session-shell-root.js +100 -260
  27. package/dist/integrations/pi/{owned-ui → session-ui}/session-shell.d.ts +1 -1
  28. package/dist/integrations/pi/{owned-ui → session-ui}/session-shell.js +52 -12
  29. package/dist/integrations/pi/session-ui/session-viewport-controller.d.ts +39 -0
  30. package/dist/integrations/pi/session-ui/session-viewport-controller.js +369 -0
  31. package/dist/integrations/pi/session-ui/system-clipboard.d.ts +11 -0
  32. package/dist/integrations/pi/session-ui/system-clipboard.js +101 -0
  33. package/dist/integrations/pi/tui-runtime/contracts.d.ts +2 -6
  34. package/dist/native/darwin-arm64/manifest.json +1 -1
  35. package/dist/native/linux-x64/manifest.json +3 -3
  36. package/dist/native/linux-x64/process-guardian +0 -0
  37. package/dist/native/win32-x64/manifest.json +3 -3
  38. package/dist/native/win32-x64/process-guardian.exe +0 -0
  39. package/dist/ui/apps/contracts.d.ts +23 -1
  40. package/dist/ui/apps/host.js +9 -1
  41. package/dist/ui/components/scrollbar.d.ts +2 -2
  42. package/dist/ui/components/scrollbar.js +3 -3
  43. package/dist/ui/components/spans.d.ts +16 -1
  44. package/dist/ui/components/spans.js +144 -2
  45. package/dist/ui/components/text.js +24 -2
  46. package/dist/ui/components/transcript-viewport.d.ts +6 -0
  47. package/dist/ui/components/transcript-viewport.js +36 -8
  48. package/dist/ui/settings/declarations.d.ts +1 -1
  49. package/dist/ui/settings/declarations.js +3 -3
  50. package/dist/ui/settings/migrations.js +9 -0
  51. package/docs/architecture/boundaries.md +1 -1
  52. package/docs/architecture/project-structure.md +1 -1
  53. package/docs/ci-release-runbook.md +6 -5
  54. package/docs/features/launch-profiles.md +3 -3
  55. package/package.json +4 -1
  56. package/dist/integrations/pi/owned-ui/route-host.d.ts +0 -29
  57. /package/dist/integrations/pi/{owned-ui → session-ui}/index.d.ts +0 -0
  58. /package/dist/integrations/pi/{owned-ui → session-ui}/index.js +0 -0
  59. /package/dist/integrations/pi/{owned-ui → session-ui}/route-host.js +0 -0
@@ -1,7 +1,8 @@
1
1
  import { getSelectListTheme } from "@earendil-works/pi-coding-agent";
2
- import { CombinedAutocompleteProvider, setKeybindings, } from "#pi-tui";
2
+ import { CombinedAutocompleteProvider, matchesKey, setKeybindings, } from "#pi-tui";
3
3
  import { KeybindingsManager } from "./upstream/adjacent/core/keybindings.js";
4
4
  import { OwnedEditor } from "./upstream/components/owned-editor.js";
5
+ import { OwnedEditorUxInterception, createPromptSelectionInterceptor, } from "./owned-editor-ux.js";
5
6
  import { PINNED_PI_LAYOUT, piTheme, } from "./theme.js";
6
7
  import { createTuiFacade, ensureTheme, isAutocompleteProvider, } from "./shell-shared-facade.js";
7
8
  export const PINNED_PI_BUILTIN_SLASH_COMMANDS = [
@@ -31,7 +32,9 @@ export const PINNED_PI_BUILTIN_SLASH_COMMANDS = [
31
32
  export function createPiShellEditor(options) {
32
33
  ensureTheme();
33
34
  const tui = createTuiFacade(options);
34
- const keybindings = KeybindingsManager.create(options.agentDir);
35
+ const keybindings = options.keybindingProfile === "a1"
36
+ ? KeybindingsManager.createForOwnedInput(options.agentDir)
37
+ : KeybindingsManager.create(options.agentDir);
35
38
  setKeybindings(keybindings);
36
39
  const editor = new OwnedEditor(tui, {
37
40
  borderColor: (value) => piTheme().fg("borderMuted", value),
@@ -40,6 +43,24 @@ export function createPiShellEditor(options) {
40
43
  paddingX: PINNED_PI_LAYOUT.editorPaddingX,
41
44
  autocompleteMaxVisible: PINNED_PI_LAYOUT.autocompleteMaxVisible,
42
45
  });
46
+ const editorUx = options.keybindingProfile === "a1"
47
+ ? new OwnedEditorUxInterception([
48
+ createPromptSelectionInterceptor(editor, keybindings, {
49
+ copyText: options.onCopyText ?? (() => { }),
50
+ readClipboardContent: options.readClipboardContent ?? (async () => null),
51
+ transformPastedContent: options.transformPastedContent ?? (content => content.kind === "text" ? content.text : ""),
52
+ atomicRanges: options.editorAtomicRanges ?? (() => []),
53
+ expandCopiedText: options.expandCopiedEditorText ?? (text => text),
54
+ paintSelection: options.paintEditorSelection ?? (line => line),
55
+ decorateRow: options.decorateEditorRow ?? (row => row),
56
+ requestRender: options.requestRender,
57
+ getRows: options.getRows,
58
+ }),
59
+ ], {
60
+ render: width => editor.render(width),
61
+ handleInput: data => editor.handleInput(data),
62
+ })
63
+ : undefined;
43
64
  let thinkingLevel = "off";
44
65
  let isBashMode = false;
45
66
  const updateBorderColor = () => {
@@ -108,15 +129,28 @@ export function createPiShellEditor(options) {
108
129
  if (options.onDequeue !== undefined)
109
130
  editor.onAction("app.message.dequeue", options.onDequeue);
110
131
  return {
111
- render: width => editor.render(width),
112
- handleInput: data => editor.handleInput(data),
132
+ render: width => editorUx?.render(width) ?? editor.render(width),
133
+ activateKeybindings: () => setKeybindings(keybindings),
134
+ matchesTerminalKey: (data, key) => matchesKey(data, key),
135
+ handleInput: data => {
136
+ if (editorUx === undefined)
137
+ editor.handleInput(data);
138
+ else
139
+ editorUx.handleInput(data);
140
+ },
113
141
  invalidate: () => editor.invalidate(),
114
142
  setFocused: focused => {
115
143
  editor.focused = focused;
116
144
  },
117
145
  getText: () => editor.getExpandedText(),
118
- setText: text => editor.setText(text),
119
- insertText: text => editor.insertTextAtCursor(text),
146
+ setText: text => {
147
+ editorUx?.reset();
148
+ editor.setText(text);
149
+ },
150
+ insertText: text => {
151
+ editorUx?.reset();
152
+ editor.insertTextAtCursor(text);
153
+ },
120
154
  addToHistory: text => editor.addToHistory(text),
121
155
  setSubmitEnabled: enabled => {
122
156
  editor.disableSubmit = !enabled;
@@ -139,6 +173,10 @@ export function createPiShellEditor(options) {
139
173
  thinkingLevel = level;
140
174
  updateBorderColor();
141
175
  },
176
+ hasSelection: () => editorUx?.hasSelection() ?? false,
177
+ ownsPointer: () => editorUx?.ownsPointer() ?? false,
178
+ handlePointer: event => editorUx?.handlePointer(event) ?? false,
179
+ pasteClipboard: () => editorUx?.pasteClipboard() ?? false,
142
180
  };
143
181
  }
144
182
  function autocompleteCommand(command, addition) {
@@ -1,13 +1,34 @@
1
- import { type AutocompleteProvider, type Component, type TUI } from "#pi-tui";
1
+ import { visibleWidth, type AutocompleteProvider, type Component, type TUI } from "#pi-tui";
2
2
  import type { OwnedUiSessionViewModel, OwnedUiThinkingLevel, OwnedUiTranscriptBlock } from "../../../contracts/owned-ui/index.js";
3
- export interface PiShellComponentPort {
4
- render(width: number): readonly string[];
5
- handleInput?(data: string): void;
6
- invalidate(): void;
7
- setFocused?(focused: boolean): void;
8
- dispose?(): void;
3
+ import type { PresentationComponentPort } from "../../../contracts/presentation/index.js";
4
+ /** Terminal-cell width authority used by Pi-rendered component rows. */
5
+ export declare const piShellVisibleWidth: typeof visibleWidth;
6
+ export interface PiShellComponentPort extends PresentationComponentPort {
7
+ }
8
+ export type PiShellClipboardContent = {
9
+ readonly kind: "text";
10
+ readonly text: string;
11
+ } | {
12
+ readonly kind: "image";
13
+ readonly data: string;
14
+ readonly mimeType: string;
15
+ };
16
+ export interface PiShellEditorTextRange {
17
+ readonly start: number;
18
+ readonly end: number;
19
+ }
20
+ export interface PiShellEditorPointerEvent {
21
+ readonly kind: "press" | "motion" | "release";
22
+ readonly button: number;
23
+ readonly column: number;
24
+ /** One-based row relative to the top of the editor component. */
25
+ readonly row: number;
9
26
  }
10
27
  export interface PiShellEditorPort extends PiShellComponentPort {
28
+ /** Restores this editor's profile after another Pi component changed the global manager. */
29
+ activateKeybindings(): void;
30
+ /** Uses Pi's terminal decoder rather than assuming one terminal escape spelling. */
31
+ matchesTerminalKey(data: string, key: "home" | "end" | "ctrl+v"): boolean;
11
32
  getText(): string;
12
33
  setText(text: string): void;
13
34
  insertText(text: string): void;
@@ -18,6 +39,10 @@ export interface PiShellEditorPort extends PiShellComponentPort {
18
39
  setAutocompleteCommands(commands: readonly PiShellAutocompleteCommand[]): void;
19
40
  addAutocompleteProvider(factory: unknown): void;
20
41
  setThinkingLevel(level: OwnedUiThinkingLevel): void;
42
+ hasSelection(): boolean;
43
+ ownsPointer(): boolean;
44
+ handlePointer(event: PiShellEditorPointerEvent): boolean;
45
+ pasteClipboard(): boolean;
21
46
  }
22
47
  export interface PiShellAutocompleteCommand {
23
48
  readonly name: string;
@@ -69,6 +94,8 @@ export interface PiShellHeaderOptions {
69
94
  readonly resources?: readonly PiShellResourceEntry[];
70
95
  }
71
96
  export interface PiShellEditorOptions {
97
+ /** Bare A1 adds ergonomic aliases while comparison profiles retain Pi defaults. */
98
+ readonly keybindingProfile?: "pi" | "a1";
72
99
  readonly getColumns: () => number;
73
100
  readonly getRows: () => number;
74
101
  readonly requestRender: () => void;
@@ -89,6 +116,13 @@ export interface PiShellEditorOptions {
89
116
  readonly onMessageCopy?: (() => void) | undefined;
90
117
  readonly onFollowUp?: (() => void) | undefined;
91
118
  readonly onDequeue?: (() => void) | undefined;
119
+ readonly onCopyText?: (text: string) => void;
120
+ readonly readClipboardContent?: () => Promise<PiShellClipboardContent | null>;
121
+ readonly transformPastedContent?: (content: PiShellClipboardContent) => string;
122
+ readonly editorAtomicRanges?: (line: string) => readonly PiShellEditorTextRange[];
123
+ readonly expandCopiedEditorText?: (text: string) => string;
124
+ readonly paintEditorSelection?: (line: string, from: number, to: number, atomic: boolean) => string;
125
+ readonly decorateEditorRow?: (row: string, width: number) => string;
92
126
  readonly cwd?: string;
93
127
  readonly agentDir?: string;
94
128
  readonly autocompleteCommands?: readonly PiShellAutocompleteCommand[];
@@ -1,6 +1,8 @@
1
- import { setKeybindings, } from "#pi-tui";
1
+ import { setKeybindings, visibleWidth, } from "#pi-tui";
2
2
  import { KeybindingsManager, } from "./upstream/adjacent/core/keybindings.js";
3
3
  import { ensurePiTheme, } from "./theme.js";
4
+ /** Terminal-cell width authority used by Pi-rendered component rows. */
5
+ export const piShellVisibleWidth = visibleWidth;
4
6
  // Pinned from packages/coding-agent/src/core/slash-commands.ts at 914cf14.
5
7
  export function createTuiFacade(options) {
6
8
  const children = [];
@@ -1,5 +1,5 @@
1
1
  /** Source-synchronized from Pi 0.84.2 packages/coding-agent/src/core/keybindings.ts. */
2
- import { type Keybinding, type KeybindingsConfig, type KeyId, KeybindingsManager as TuiKeybindingsManager } from "#pi-tui";
2
+ import { type Keybinding, type KeybindingDefinitions, type KeybindingsConfig, type KeyId, KeybindingsManager as TuiKeybindingsManager } from "#pi-tui";
3
3
  export interface AppKeybindings {
4
4
  "app.interrupt": true;
5
5
  "app.clear": true;
@@ -43,6 +43,12 @@ export interface AppKeybindings {
43
43
  "app.tree.filter.all": true;
44
44
  "app.tree.filter.cycleForward": true;
45
45
  "app.tree.filter.cycleBackward": true;
46
+ "owned.editor.selectAll": true;
47
+ "owned.editor.cut": true;
48
+ "owned.editor.paste": true;
49
+ "owned.editor.redo": true;
50
+ "owned.editor.extendLeft": true;
51
+ "owned.editor.extendRight": true;
46
52
  }
47
53
  export type AppKeybinding = keyof AppKeybindings;
48
54
  declare module "#pi-tui" {
@@ -413,8 +419,10 @@ export declare function migrateKeybindingsConfig(rawConfig: Record<string, unkno
413
419
  };
414
420
  export declare class KeybindingsManager extends TuiKeybindingsManager {
415
421
  private configPath;
416
- constructor(userBindings?: KeybindingsConfig, configPath?: string);
422
+ constructor(userBindings?: KeybindingsConfig, configPath?: string, definitions?: KeybindingDefinitions);
417
423
  static create(agentDir?: string): KeybindingsManager;
424
+ static createForOwnedInput(agentDir?: string): KeybindingsManager;
425
+ private static createWithDefinitions;
418
426
  reload(): void;
419
427
  getEffectiveConfig(): KeybindingsConfig;
420
428
  private static loadFromFile;
@@ -147,6 +147,33 @@ export const KEYBINDINGS = {
147
147
  description: "Tree filter: cycle backward",
148
148
  },
149
149
  };
150
+ /** Pi actions with bare-A1 aliases; the comparison profile keeps KEYBINDINGS unchanged. */
151
+ const OWNED_INPUT_KEYBINDINGS = {
152
+ ...KEYBINDINGS,
153
+ "tui.editor.deleteWordBackward": {
154
+ ...KEYBINDINGS["tui.editor.deleteWordBackward"],
155
+ defaultKeys: [...KEYBINDINGS["tui.editor.deleteWordBackward"].defaultKeys, "ctrl+backspace"],
156
+ },
157
+ "tui.editor.deleteWordForward": {
158
+ ...KEYBINDINGS["tui.editor.deleteWordForward"],
159
+ defaultKeys: [...KEYBINDINGS["tui.editor.deleteWordForward"].defaultKeys, "ctrl+delete"],
160
+ },
161
+ "tui.editor.undo": {
162
+ ...KEYBINDINGS["tui.editor.undo"],
163
+ defaultKeys: [KEYBINDINGS["tui.editor.undo"].defaultKeys, "ctrl+z"],
164
+ },
165
+ // Ctrl+Z edits the prompt in bare A1 instead of suspending the process.
166
+ "app.suspend": { ...KEYBINDINGS["app.suspend"], defaultKeys: [] },
167
+ // Owned prompt-selection actions are intercepted before vanilla Pi editor
168
+ // actions. Keeping them in the keybinding manager makes the UX declarative,
169
+ // configurable, and independent of terminal escape-sequence spellings.
170
+ "owned.editor.selectAll": { defaultKeys: "ctrl+a", description: "Select all prompt text" },
171
+ "owned.editor.cut": { defaultKeys: "ctrl+x", description: "Cut selected prompt text" },
172
+ "owned.editor.paste": { defaultKeys: "ctrl+v", description: "Paste clipboard text" },
173
+ "owned.editor.redo": { defaultKeys: "ctrl+y", description: "Redo prompt edit" },
174
+ "owned.editor.extendLeft": { defaultKeys: "shift+left", description: "Extend prompt selection left" },
175
+ "owned.editor.extendRight": { defaultKeys: "shift+right", description: "Extend prompt selection right" },
176
+ };
150
177
  const KEYBINDING_NAME_MIGRATIONS = {
151
178
  cursorUp: "tui.editor.cursorUp",
152
179
  cursorDown: "tui.editor.cursorDown",
@@ -270,14 +297,20 @@ function loadRawConfig(path) {
270
297
  }
271
298
  export class KeybindingsManager extends TuiKeybindingsManager {
272
299
  configPath;
273
- constructor(userBindings = {}, configPath) {
274
- super(KEYBINDINGS, userBindings);
300
+ constructor(userBindings = {}, configPath, definitions = KEYBINDINGS) {
301
+ super(definitions, userBindings);
275
302
  this.configPath = configPath;
276
303
  }
277
304
  static create(agentDir = getAgentDir()) {
305
+ return KeybindingsManager.createWithDefinitions(agentDir, KEYBINDINGS);
306
+ }
307
+ static createForOwnedInput(agentDir = getAgentDir()) {
308
+ return KeybindingsManager.createWithDefinitions(agentDir, OWNED_INPUT_KEYBINDINGS);
309
+ }
310
+ static createWithDefinitions(agentDir, definitions) {
278
311
  const configPath = join(agentDir, "keybindings.json");
279
312
  const userBindings = KeybindingsManager.loadFromFile(configPath);
280
- return new KeybindingsManager(userBindings, configPath);
313
+ return new KeybindingsManager(userBindings, configPath, definitions);
281
314
  }
282
315
  reload() {
283
316
  if (!this.configPath)
@@ -1320,7 +1320,11 @@ export class PiEngineAdapter {
1320
1320
  case "retry":
1321
1321
  case "compact": {
1322
1322
  const result = await this.#sessionCommands?.execute(command.type === "prompt" || command.type === "steer" || command.type === "follow-up"
1323
- ? { type: command.type, text: command.text }
1323
+ ? {
1324
+ type: command.type,
1325
+ text: command.text,
1326
+ ...(command.images === undefined ? {} : { images: [...command.images] }),
1327
+ }
1324
1328
  : { type: command.type });
1325
1329
  if (!result || result.outcome === "rejected" || result.outcome === "failed") {
1326
1330
  throw new Error(command.type === "retry" ? "no previous prompt is available to retry" : `Pi session command failed: ${command.type}`);
@@ -1,4 +1,4 @@
1
- import type { AgentSession, AgentSessionEvent } from "@earendil-works/pi-coding-agent";
1
+ import type { AgentSession, AgentSessionEvent, PromptOptions } from "@earendil-works/pi-coding-agent";
2
2
  import { type AgentCommandOutcome, type AgentEvent } from "../../../contracts/agent-engine/index.js";
3
3
  export interface PiDocumentedSessionCommands {
4
4
  readonly isStreaming: AgentSession["isStreaming"];
@@ -18,6 +18,7 @@ export interface PiDocumentedSessionCommands {
18
18
  export type PiSessionCommand = {
19
19
  readonly type: "prompt" | "steer" | "follow-up";
20
20
  readonly text: string;
21
+ readonly images?: NonNullable<PromptOptions["images"]>;
21
22
  } | {
22
23
  readonly type: "abort" | "retry" | "compact";
23
24
  } | {
@@ -9,18 +9,29 @@ export class PiSessionCommandIntegration {
9
9
  switch (command.type) {
10
10
  case "prompt":
11
11
  this.#lastPrompt = command.text;
12
- await this.session.prompt(command.text, this.session.isStreaming ? { streamingBehavior: "followUp" } : undefined);
12
+ await this.session.prompt(command.text, command.images === undefined
13
+ ? this.session.isStreaming ? { streamingBehavior: "followUp" } : undefined
14
+ : {
15
+ ...(this.session.isStreaming ? { streamingBehavior: "followUp" } : {}),
16
+ images: [...command.images],
17
+ });
13
18
  return { outcome: "completed" };
14
19
  case "steer":
15
20
  this.#lastPrompt = command.text;
16
21
  // Match interactive Pi: prompt() owns template/extension expansion and
17
22
  // turns the accepted steering message into the visible user row while
18
23
  // later messages remain in the pending queue.
19
- await this.session.prompt(command.text, { streamingBehavior: "steer" });
24
+ await this.session.prompt(command.text, {
25
+ streamingBehavior: "steer",
26
+ ...(command.images === undefined ? {} : { images: [...command.images] }),
27
+ });
20
28
  return { outcome: "completed" };
21
29
  case "follow-up":
22
30
  this.#lastPrompt = command.text;
23
- await this.session.prompt(command.text, { streamingBehavior: "followUp" });
31
+ await this.session.prompt(command.text, {
32
+ streamingBehavior: "followUp",
33
+ ...(command.images === undefined ? {} : { images: [...command.images] }),
34
+ });
24
35
  return { outcome: "completed" };
25
36
  case "abort":
26
37
  if (this.session.isRetrying)
@@ -0,0 +1,23 @@
1
+ import type { PiShellClipboardContent, PiShellEditorTextRange } from "../components/index.js";
2
+ export interface PromptImageAttachment {
3
+ readonly type: "image";
4
+ readonly data: string;
5
+ readonly mimeType: string;
6
+ }
7
+ export interface PreparedPrompt {
8
+ readonly text: string;
9
+ readonly images: readonly PromptImageAttachment[];
10
+ }
11
+ /** Owns semantic clipboard records while the prompt displays compact chips. */
12
+ export declare class PromptChipStore {
13
+ #private;
14
+ transformPastedContent(content: PiShellClipboardContent): string;
15
+ atomicRanges(line: string): readonly PiShellEditorTextRange[];
16
+ hyperlinkRanges(text: string): readonly {
17
+ start: number;
18
+ end: number;
19
+ target: string;
20
+ }[];
21
+ expandCopiedText(text: string): string;
22
+ prepareSubmission(text: string): PreparedPrompt;
23
+ }
@@ -0,0 +1,242 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { existsSync, statSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ const CHIP_PATTERN = /\[(?:📷 [^\]]+|📁 [^\]]+|📄 [^\]]+|🖼 {1,2}[^\]]+|🔗 [^\]]+)\]/gu;
6
+ const IMAGE_EXTENSION = /\.(?:jpe?g|png|webp|gif|bmp|tiff?)$/iu;
7
+ const URL_PATTERN = /^https?:\/\/[^\s\u0000-\u001f\u007f]+$/iu;
8
+ const URL_DISPLAY_LENGTH = 40;
9
+ /** Owns semantic clipboard records while the prompt displays compact chips. */
10
+ export class PromptChipStore {
11
+ #chips = new Map();
12
+ transformPastedContent(content) {
13
+ if (content.kind === "image") {
14
+ const id = randomBytes(5).toString("hex");
15
+ const tag = `[📷 screenshot-${id}.png]`;
16
+ this.#chips.set(tag, {
17
+ kind: "image",
18
+ tag,
19
+ image: { type: "image", data: content.data, mimeType: content.mimeType },
20
+ });
21
+ return tag;
22
+ }
23
+ const text = content.text;
24
+ if (this.#chips.has(text.trim()))
25
+ return text.trim();
26
+ const url = text.trim();
27
+ if (URL_PATTERN.test(url)) {
28
+ const label = url.length <= URL_DISPLAY_LENGTH ? url : `${url.slice(0, URL_DISPLAY_LENGTH)}…`;
29
+ return this.#recordUnique({ kind: "url", tag: `[🔗 ${label}]`, label, url });
30
+ }
31
+ const paths = pathsFromClipboard(text);
32
+ if (paths.length === 0)
33
+ return text;
34
+ return paths.map(item => {
35
+ const label = pathLabel(item.fullPath);
36
+ if (item.kind === "folder") {
37
+ return this.#recordUnique({ kind: "folder", tag: `[📁 ${label}]`, path: item.fullPath });
38
+ }
39
+ const icon = IMAGE_EXTENSION.test(item.fullPath) ? "🖼 " : "📄";
40
+ return this.#recordUnique({ kind: "file", tag: `[${icon} ${label}]`, path: item.fullPath });
41
+ }).join("");
42
+ }
43
+ atomicRanges(line) {
44
+ return [...line.matchAll(CHIP_PATTERN)].map(match => ({
45
+ start: match.index,
46
+ end: match.index + match[0].length,
47
+ }));
48
+ }
49
+ hyperlinkRanges(text) {
50
+ const ranges = [];
51
+ for (const chip of this.#chips.values()) {
52
+ if (chip.kind !== "url")
53
+ continue;
54
+ const labelOffset = chip.tag.indexOf(chip.label);
55
+ if (labelOffset < 0)
56
+ continue;
57
+ let searchFrom = 0;
58
+ while (searchFrom <= text.length - chip.tag.length) {
59
+ const tagStart = text.indexOf(chip.tag, searchFrom);
60
+ if (tagStart < 0)
61
+ break;
62
+ const start = tagStart + labelOffset;
63
+ ranges.push({ start, end: start + chip.label.length, target: chip.url });
64
+ searchFrom = tagStart + chip.tag.length;
65
+ }
66
+ }
67
+ return ranges.sort((left, right) => right.start - left.start);
68
+ }
69
+ expandCopiedText(text) {
70
+ return this.#replaceResolvable(text, false).text;
71
+ }
72
+ prepareSubmission(text) {
73
+ const expanded = this.#replaceResolvable(text, true);
74
+ return { text: expanded.text, images: expanded.images };
75
+ }
76
+ #replaceResolvable(text, includeImages) {
77
+ let expanded = text;
78
+ const images = [];
79
+ const seenImages = new Set();
80
+ for (const [tag, chip] of this.#chips) {
81
+ if (!expanded.includes(tag))
82
+ continue;
83
+ if (chip.kind === "image") {
84
+ if (includeImages && !seenImages.has(tag)) {
85
+ images.push(chip.image);
86
+ seenImages.add(tag);
87
+ }
88
+ continue;
89
+ }
90
+ const value = chip.kind === "url" ? chip.url : chip.path;
91
+ expanded = expanded.replaceAll(tag, value);
92
+ }
93
+ return { text: expanded, images };
94
+ }
95
+ #recordUnique(chip) {
96
+ const existing = this.#chips.get(chip.tag);
97
+ if (existing === undefined || sameChipValue(existing, chip)) {
98
+ this.#chips.set(chip.tag, chip);
99
+ return chip.tag;
100
+ }
101
+ const suffix = randomBytes(2).toString("hex");
102
+ const uniqueTag = `${chip.tag.slice(0, -1)} #${suffix}]`;
103
+ const unique = { ...chip, tag: uniqueTag };
104
+ this.#chips.set(uniqueTag, unique);
105
+ return uniqueTag;
106
+ }
107
+ }
108
+ function pathsFromClipboard(text) {
109
+ const normalized = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").trim();
110
+ if (normalized.length === 0)
111
+ return [];
112
+ const paths = [];
113
+ for (const line of normalized.split("\n").filter(value => value.trim().length > 0)) {
114
+ const wholeLine = existingClipboardPath(line);
115
+ if (wholeLine !== null) {
116
+ paths.push(wholeLine);
117
+ continue;
118
+ }
119
+ const tokens = tokenizePathLine(line)?.filter(token => token.quoted || token.text !== "&");
120
+ if (tokens === undefined || tokens.length === 0)
121
+ return [];
122
+ for (let index = 0; index < tokens.length;) {
123
+ const token = tokens[index];
124
+ if (token === undefined)
125
+ return [];
126
+ if (token.quoted) {
127
+ const quoted = existingClipboardPath(token.text);
128
+ if (quoted === null)
129
+ return [];
130
+ paths.push(quoted);
131
+ index += 1;
132
+ continue;
133
+ }
134
+ if (normalizePath(token.text) === null)
135
+ return [];
136
+ let runEnd = index;
137
+ while (runEnd < tokens.length && tokens[runEnd]?.quoted !== true)
138
+ runEnd += 1;
139
+ let matched = null;
140
+ let matchedEnd = index;
141
+ for (let end = runEnd; end > index; end -= 1) {
142
+ matched = existingClipboardPath(tokens.slice(index, end).map(candidate => candidate.text).join(" "));
143
+ if (matched !== null) {
144
+ matchedEnd = end;
145
+ break;
146
+ }
147
+ }
148
+ if (matched === null)
149
+ return [];
150
+ paths.push(matched);
151
+ index = matchedEnd;
152
+ }
153
+ }
154
+ return paths;
155
+ }
156
+ function existingClipboardPath(value) {
157
+ const fullPath = normalizePath(unquote(value.trim()));
158
+ if (fullPath === null || !existsSync(fullPath))
159
+ return null;
160
+ try {
161
+ const stat = statSync(fullPath);
162
+ if (stat.isDirectory())
163
+ return { fullPath, kind: "folder" };
164
+ if (stat.isFile())
165
+ return { fullPath, kind: "file" };
166
+ }
167
+ catch {
168
+ // Clipboard path probing is best-effort.
169
+ }
170
+ return null;
171
+ }
172
+ function tokenizePathLine(line) {
173
+ const tokens = [];
174
+ let index = 0;
175
+ while (index < line.length) {
176
+ while (index < line.length && /\s/u.test(line[index] ?? ""))
177
+ index += 1;
178
+ if (index >= line.length)
179
+ break;
180
+ const quote = line[index] === '"' || line[index] === "'" ? line[index] : undefined;
181
+ if (quote !== undefined) {
182
+ index += 1;
183
+ const start = index;
184
+ while (index < line.length && line[index] !== quote)
185
+ index += 1;
186
+ if (index >= line.length)
187
+ return undefined;
188
+ tokens.push({ text: line.slice(start, index), quoted: true });
189
+ index += 1;
190
+ if (index < line.length && !/\s/u.test(line[index] ?? ""))
191
+ return undefined;
192
+ continue;
193
+ }
194
+ const start = index;
195
+ while (index < line.length && !/\s/u.test(line[index] ?? ""))
196
+ index += 1;
197
+ tokens.push({ text: line.slice(start, index), quoted: false });
198
+ }
199
+ return tokens;
200
+ }
201
+ function normalizePath(value) {
202
+ let candidate = value;
203
+ if (/^file:\/\//iu.test(candidate)) {
204
+ try {
205
+ candidate = fileURLToPath(candidate);
206
+ }
207
+ catch {
208
+ return null;
209
+ }
210
+ }
211
+ if (process.platform === "win32") {
212
+ const msys = /^\/([A-Za-z])\/(.*)$/u.exec(candidate);
213
+ if (msys !== null)
214
+ candidate = `${msys[1]}:\\${msys[2]?.replaceAll("/", "\\") ?? ""}`;
215
+ }
216
+ return /^(?:[A-Za-z]:\\|\/)/u.test(candidate) ? path.normalize(candidate) : null;
217
+ }
218
+ function unquote(value) {
219
+ let result = value.startsWith("& ") ? value.slice(2).trim() : value;
220
+ if ((result.startsWith('"') && result.endsWith('"')) || (result.startsWith("'") && result.endsWith("'"))) {
221
+ result = result.slice(1, -1).trim();
222
+ }
223
+ return result;
224
+ }
225
+ function pathLabel(fullPath) {
226
+ const basename = path.basename(fullPath);
227
+ if (basename.length > 0)
228
+ return basename;
229
+ return path.parse(fullPath).root.replace(/[\\/]+$/u, "") || fullPath;
230
+ }
231
+ function sameChipValue(left, right) {
232
+ if (left.kind !== right.kind)
233
+ return false;
234
+ if (left.kind === "url" && right.kind === "url")
235
+ return left.url === right.url;
236
+ if ((left.kind === "file" || left.kind === "folder") && (right.kind === "file" || right.kind === "folder")) {
237
+ return path.normalize(left.path) === path.normalize(right.path);
238
+ }
239
+ if (left.kind === "image" && right.kind === "image")
240
+ return left.image.data === right.image.data;
241
+ return false;
242
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Compatibility export. Owned application route lifecycle belongs to the
3
+ * vendor-neutral UI app owner; the Pi shell only hosts that contract.
4
+ */
5
+ export type { UiRouteHost, UiRouteSurface } from "../../../ui/apps/index.js";
@@ -1,11 +1,20 @@
1
1
  import type { OwnedUiSessionViewModel, OwnedUiViewportSettings, OwnedUiViewportSettingsPort } from "../../../contracts/owned-ui/index.js";
2
- import type { UiRouteHost } from "./route-host.js";
2
+ import type { UiRouteHost } from "../../../ui/apps/index.js";
3
3
  import { type PiEngineAdapter, type PiWorkflowResult } from "../engine/index.js";
4
- import { type PiShellComponentPort, type PiShellEditorPort, type PiShellExtensionRendererResolver, type PiShellHeaderOptions, type PiShellHeaderPort, type PiShellLoadedResourcesPort, type PiShellResourceEntry, type PiShellTranscriptComponentPort } from "../components/index.js";
4
+ import { type PiShellClipboardContent, type PiShellComponentPort, type PiShellEditorPort, type PiShellExtensionRendererResolver, type PiShellHeaderOptions, type PiShellHeaderPort, type PiShellLoadedResourcesPort, type PiShellResourceEntry, type PiShellTranscriptComponentPort } from "../components/index.js";
5
5
  import { type PiTuiComponentPort, type PiTuiLayoutNode, type PiTuiTerminalPort } from "../tui-runtime/index.js";
6
+ import { type PreparedPrompt } from "./prompt-chips.js";
6
7
  export type OwnedUiBackendPort = PiEngineAdapter;
7
8
  export type OwnedUiTerminalPort = PiTuiTerminalPort;
8
9
  type OwnedUiStartupOptions = PiShellHeaderOptions;
10
+ export interface OwnedUiClipboardPort {
11
+ readText(): Promise<string | null>;
12
+ readImage?(): Promise<{
13
+ readonly data: string;
14
+ readonly mimeType: string;
15
+ } | null>;
16
+ writeText?(text: string): Promise<void>;
17
+ }
9
18
  export interface OwnedUiSessionShellOptions {
10
19
  readonly backend: OwnedUiBackendPort;
11
20
  readonly cwd: string;
@@ -20,6 +29,8 @@ export interface OwnedUiSessionShellOptions {
20
29
  readonly sessionLayout?: "pinned" | "custom-viewport";
21
30
  /** Live profile-local settings, supplied only to the bare-A1 composition. */
22
31
  readonly viewportSettings?: OwnedUiViewportSettingsPort;
32
+ /** Optional platform seam; production uses A1's system clipboard adapter. */
33
+ readonly clipboard?: OwnedUiClipboardPort;
23
34
  }
24
35
  export declare class OwnedUiSessionShellRoot implements PiTuiComponentPort {
25
36
  #private;
@@ -29,7 +40,7 @@ export declare class OwnedUiSessionShellRoot implements PiTuiComponentPort {
29
40
  constructor(view: OwnedUiSessionViewModel, cwd: string, handlers: {
30
41
  readonly getColumns: () => number;
31
42
  readonly getRows: () => number;
32
- readonly requestRender: () => void;
43
+ readonly requestRender: (force?: boolean) => void;
33
44
  readonly onSubmit: (text: string) => void;
34
45
  readonly onInterrupt: () => void;
35
46
  readonly onClear?: () => void;
@@ -41,7 +52,10 @@ export declare class OwnedUiSessionShellRoot implements PiTuiComponentPort {
41
52
  readonly onMessageCopy?: () => void;
42
53
  readonly onFollowUp?: () => void;
43
54
  readonly onDequeue?: () => void;
55
+ readonly onCopyText?: (text: string) => void;
56
+ readonly readClipboardContent?: () => Promise<PiShellClipboardContent | null>;
44
57
  }, startup?: PiShellHeaderOptions, agentDir?: string, extensionRenderers?: PiShellExtensionRendererResolver, sessionLayout?: "pinned" | "custom-viewport");
58
+ preparePromptSubmission(text: string): PreparedPrompt;
45
59
  update(view: OwnedUiSessionViewModel): void;
46
60
  /**
47
61
  * Applies one block: its component is created or updated in place and the order grows