@pencil-agent/nano-pencil 1.11.32 → 1.11.33

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.
@@ -83,6 +83,15 @@ export declare class ModelRegistry {
83
83
  */
84
84
  registerProvider(providerName: string, config: ProviderConfigInput): void;
85
85
  private applyProviderConfig;
86
+ private static readonly OPENROUTER_JSON_BASE;
87
+ private static readonly OPENROUTER_JSON_API;
88
+ /**
89
+ * Append an OpenRouter model to models.json by id (same string as on openrouter.ai, e.g. x-ai/grok-4.20).
90
+ * API key is not written; use /login openrouter or OPENROUTER_API_KEY.
91
+ */
92
+ appendOpenRouterModel(modelId: string, options?: {
93
+ name?: string;
94
+ }): void;
86
95
  }
87
96
  /**
88
97
  * Input type for registerProvider API.
@@ -7,7 +7,7 @@
7
7
  import { getModels, getProviders, registerApiProvider, registerOAuthProvider, } from "@pencil-agent/ai";
8
8
  import { Type } from "@sinclair/typebox";
9
9
  import AjvModule from "ajv";
10
- import { existsSync, readFileSync } from "fs";
10
+ import { existsSync, readFileSync, writeFileSync } from "fs";
11
11
  import { join } from "path";
12
12
  import { getAgentDir } from "../config.js";
13
13
  import { clearConfigValueCache, resolveConfigValue, resolveHeaders } from "./config/resolve-config-value.js";
@@ -536,5 +536,54 @@ export class ModelRegistry {
536
536
  });
537
537
  }
538
538
  }
539
+ static OPENROUTER_JSON_BASE = "https://openrouter.ai/api/v1";
540
+ static OPENROUTER_JSON_API = "openai-completions";
541
+ /**
542
+ * Append an OpenRouter model to models.json by id (same string as on openrouter.ai, e.g. x-ai/grok-4.20).
543
+ * API key is not written; use /login openrouter or OPENROUTER_API_KEY.
544
+ */
545
+ appendOpenRouterModel(modelId, options) {
546
+ const providerKey = "openrouter";
547
+ const trimmed = modelId.trim();
548
+ if (!trimmed) {
549
+ throw new Error("OpenRouter model id cannot be empty");
550
+ }
551
+ const modelsPath = this.modelsJsonPath;
552
+ if (!modelsPath) {
553
+ throw new Error("models.json path is not configured");
554
+ }
555
+ let data;
556
+ if (existsSync(modelsPath)) {
557
+ const raw = readFileSync(modelsPath, "utf-8");
558
+ data = JSON.parse(raw);
559
+ }
560
+ else {
561
+ data = { providers: {} };
562
+ }
563
+ if (!data.providers || typeof data.providers !== "object") {
564
+ data.providers = {};
565
+ }
566
+ const existing = data.providers[providerKey] ?? {};
567
+ const prevModels = Array.isArray(existing.models) ? [...existing.models] : [];
568
+ if (prevModels.some((m) => m && typeof m === "object" && m.id === trimmed)) {
569
+ throw new Error(`OpenRouter model "${trimmed}" already exists in models.json`);
570
+ }
571
+ const displayName = options?.name?.trim() || trimmed;
572
+ prevModels.push({
573
+ id: trimmed,
574
+ name: displayName,
575
+ input: ["text"],
576
+ contextWindow: 256000,
577
+ maxTokens: 8192,
578
+ });
579
+ data.providers[providerKey] = {
580
+ ...existing,
581
+ baseUrl: existing.baseUrl ?? ModelRegistry.OPENROUTER_JSON_BASE,
582
+ api: existing.api ?? ModelRegistry.OPENROUTER_JSON_API,
583
+ models: prevModels,
584
+ };
585
+ writeFileSync(modelsPath, JSON.stringify(data, null, 2), "utf-8");
586
+ this.refresh();
587
+ }
539
588
  }
540
589
  //# sourceMappingURL=model-registry.js.map
@@ -1,6 +1,6 @@
1
1
  /**
2
- * [UPSTREAM]: Depends on @pencil-agent/tui
3
- * [SURFACE]: CustomEditor
2
+ * [UPSTREAM]:
3
+ * [SURFACE]:
4
4
  * [LOCUS]: modes/interactive/components/custom-editor.ts -
5
5
  * [COVENANT]: Change → update this header
6
6
  */
@@ -17,6 +17,8 @@ export declare class CustomEditor extends Editor {
17
17
  onPasteImage?: () => void;
18
18
  /** Handler for extension-registered shortcuts. Returns true if handled. */
19
19
  onExtensionShortcut?: (data: string) => boolean;
20
+ /** Handler for attachment navigation (arrow keys, delete). Returns true if handled. */
21
+ onAttachmentKey?: (data: string) => boolean;
20
22
  constructor(tui: TUI, theme: EditorTheme, keybindings: KeybindingsManager, options?: EditorOptions);
21
23
  /**
22
24
  * Register a handler for an app action.
@@ -1,10 +1,10 @@
1
1
  /**
2
- * [UPSTREAM]: Depends on @pencil-agent/tui
3
- * [SURFACE]: CustomEditor
2
+ * [UPSTREAM]:
3
+ * [SURFACE]:
4
4
  * [LOCUS]: modes/interactive/components/custom-editor.ts -
5
5
  * [COVENANT]: Change → update this header
6
6
  */
7
- import { Editor } from "@pencil-agent/tui";
7
+ import { Editor, getEditorKeybindings, matchesKey } from "@pencil-agent/tui";
8
8
  /**
9
9
  * Custom editor that handles app-level keybindings for coding-agent.
10
10
  */
@@ -17,6 +17,8 @@ export class CustomEditor extends Editor {
17
17
  onPasteImage;
18
18
  /** Handler for extension-registered shortcuts. Returns true if handled. */
19
19
  onExtensionShortcut;
20
+ /** Handler for attachment navigation (arrow keys, delete). Returns true if handled. */
21
+ onAttachmentKey;
20
22
  constructor(tui, theme, keybindings, options) {
21
23
  super(tui, theme, options);
22
24
  this.keybindings = keybindings;
@@ -32,10 +34,19 @@ export class CustomEditor extends Editor {
32
34
  if (this.onExtensionShortcut?.(data)) {
33
35
  return;
34
36
  }
35
- // Check for paste image keybinding
36
- if (this.keybindings.matches(data, "pasteImage")) {
37
- this.onPasteImage?.();
38
- return;
37
+ // Detect image paste via empty bracketed paste — the terminal sends an
38
+ // empty bracket sequence when the clipboard contains an image (since
39
+ // raw image bytes can't be pasted as text). This is the ONLY trigger
40
+ // for clipboard image reading; we intentionally do NOT intercept the
41
+ // raw Ctrl+V key code because Windows keeps stale image data in the
42
+ // clipboard even after the user copies text, which would cause the
43
+ // wrong content to be pasted.
44
+ if (this.onPasteImage && data.includes("\x1b[200~") && data.includes("\x1b[201~")) {
45
+ const content = data.replace("\x1b[200~", "").replace("\x1b[201~", "").trim();
46
+ if (content.length === 0) {
47
+ this.onPasteImage();
48
+ return;
49
+ }
39
50
  }
40
51
  // Check app keybindings first
41
52
  // Escape/interrupt - only if autocomplete is NOT active
@@ -69,6 +80,21 @@ export class CustomEditor extends Editor {
69
80
  return;
70
81
  }
71
82
  }
83
+ // Forward navigation/delete keys to the attachment handler. The handler
84
+ // only consumes them when appropriate (e.g. Delete only when an
85
+ // attachment is selected), otherwise returns false so the editor
86
+ // processes the key normally.
87
+ if (this.onAttachmentKey) {
88
+ const kb = getEditorKeybindings();
89
+ if (kb.matches(data, "cursorUp") ||
90
+ kb.matches(data, "cursorDown") ||
91
+ matchesKey(data, "delete") ||
92
+ matchesKey(data, "backspace")) {
93
+ if (this.onAttachmentKey(data)) {
94
+ return;
95
+ }
96
+ }
97
+ }
72
98
  // Pass to parent for editor handling
73
99
  super.handleInput(data);
74
100
  }
@@ -5,6 +5,7 @@
5
5
  * [COVENANT]: Change components → update this header
6
6
  */
7
7
  export { ArminComponent } from "./armin.js";
8
+ export { AttachmentsBarComponent } from "./attachments-bar.js";
8
9
  export { AssistantMessageComponent } from "./assistant-message.js";
9
10
  export { promptForApiKey } from "./apikey-input.js";
10
11
  export { BashExecutionComponent } from "./bash-execution.js";
@@ -6,6 +6,7 @@
6
6
  */
7
7
  // UI Components for extensions
8
8
  export { ArminComponent } from "./armin.js";
9
+ export { AttachmentsBarComponent } from "./attachments-bar.js";
9
10
  export { AssistantMessageComponent } from "./assistant-message.js";
10
11
  export { promptForApiKey } from "./apikey-input.js";
11
12
  export { BashExecutionComponent } from "./bash-execution.js";
@@ -35,9 +35,11 @@ export declare class ModelSelectorComponent extends Container implements Focusab
35
35
  private scopeText?;
36
36
  private scopeHintText?;
37
37
  private filterByProvider?;
38
+ /** When set, Ctrl+N runs this (parent closes selector and prompts for OpenRouter model id). */
39
+ private onAddOpenRouterModel?;
38
40
  get focused(): boolean;
39
41
  set focused(value: boolean);
40
- constructor(tui: TUI, currentModel: Model<any> | undefined, settingsManager: SettingsManager, modelRegistry: ModelRegistry, scopedModels: ReadonlyArray<ScopedModelItem>, ensureProviderConfigured: EnsureProviderConfigured | undefined, onSelect: (model: Model<any>) => void, onCancel: () => void, initialSearchInput?: string, filterByProvider?: string);
42
+ constructor(tui: TUI, currentModel: Model<any> | undefined, settingsManager: SettingsManager, modelRegistry: ModelRegistry, scopedModels: ReadonlyArray<ScopedModelItem>, ensureProviderConfigured: EnsureProviderConfigured | undefined, onSelect: (model: Model<any>) => void, onCancel: () => void, initialSearchInput?: string, filterByProvider?: string, onAddOpenRouterModel?: () => void);
41
43
  private loadModels;
42
44
  private sortModels;
43
45
  private getScopeText;
@@ -5,7 +5,7 @@
5
5
  * [COVENANT]: Change → update this header
6
6
  */
7
7
  import { modelsAreEqual } from "@pencil-agent/ai";
8
- import { Container, fuzzyFilter, getEditorKeybindings, Input, Spacer, Text, } from "@pencil-agent/tui";
8
+ import { Container, fuzzyFilter, getEditorKeybindings, Input, matchesKey, Spacer, Text, } from "@pencil-agent/tui";
9
9
  import { theme } from "../theme/theme.js";
10
10
  import { promptForApiKey } from "./apikey-input.js";
11
11
  import { DynamicBorder } from "./dynamic-border.js";
@@ -32,6 +32,8 @@ export class ModelSelectorComponent extends Container {
32
32
  scopeText;
33
33
  scopeHintText;
34
34
  filterByProvider;
35
+ /** When set, Ctrl+N runs this (parent closes selector and prompts for OpenRouter model id). */
36
+ onAddOpenRouterModel;
35
37
  get focused() {
36
38
  return this._focused;
37
39
  }
@@ -39,7 +41,7 @@ export class ModelSelectorComponent extends Container {
39
41
  this._focused = value;
40
42
  this.searchInput.focused = value;
41
43
  }
42
- constructor(tui, currentModel, settingsManager, modelRegistry, scopedModels, ensureProviderConfigured, onSelect, onCancel, initialSearchInput, filterByProvider) {
44
+ constructor(tui, currentModel, settingsManager, modelRegistry, scopedModels, ensureProviderConfigured, onSelect, onCancel, initialSearchInput, filterByProvider, onAddOpenRouterModel) {
43
45
  super();
44
46
  this.tui = tui;
45
47
  this.currentModel = currentModel;
@@ -48,6 +50,7 @@ export class ModelSelectorComponent extends Container {
48
50
  this.scopedModels = scopedModels;
49
51
  this.ensureProviderConfigured = ensureProviderConfigured;
50
52
  this.filterByProvider = filterByProvider;
53
+ this.onAddOpenRouterModel = onAddOpenRouterModel;
51
54
  this.scope = scopedModels.length > 0 && !filterByProvider ? "scoped" : "all";
52
55
  this.onSelectCallback = onSelect;
53
56
  this.onCancelCallback = onCancel;
@@ -73,6 +76,10 @@ export class ModelSelectorComponent extends Container {
73
76
  }
74
77
  };
75
78
  this.addChild(this.searchInput);
79
+ if (this.onAddOpenRouterModel) {
80
+ this.addChild(new Spacer(1));
81
+ this.addChild(new Text(theme.fg("muted", "Ctrl+N: add OpenRouter model by id (same as openrouter.ai)"), 0, 0));
82
+ }
76
83
  this.addChild(new Spacer(1));
77
84
  this.listContainer = new Container();
78
85
  this.addChild(this.listContainer);
@@ -255,6 +262,9 @@ export class ModelSelectorComponent extends Container {
255
262
  else if (kb.matches(keyData, "selectCancel")) {
256
263
  this.onCancelCallback();
257
264
  }
265
+ else if (this.onAddOpenRouterModel && matchesKey(keyData, "ctrl+n")) {
266
+ this.onAddOpenRouterModel();
267
+ }
258
268
  else {
259
269
  this.searchInput.handleInput(keyData);
260
270
  this.filterModels(this.searchInput.getValue());
@@ -19,6 +19,8 @@ export interface InteractiveModeOptions {
19
19
  }
20
20
  export declare class InteractiveMode {
21
21
  private options;
22
+ private static clipboardImageSeq;
23
+ private clipboardImageFiles;
22
24
  private session;
23
25
  private ui;
24
26
  private chatContainer;
@@ -71,6 +73,10 @@ export declare class InteractiveMode {
71
73
  private headerContainer;
72
74
  private builtInHeader;
73
75
  private customHeader;
76
+ private attachments;
77
+ private selectedAttachmentIndex;
78
+ private attachmentsContainer;
79
+ private attachmentsBar;
74
80
  private get agent();
75
81
  private get sessionManager();
76
82
  private get settingsManager();
@@ -194,7 +200,6 @@ export declare class InteractiveMode {
194
200
  * Show a notification for extensions.
195
201
  */
196
202
  private showExtensionNotify;
197
- private isMemoryTraceMessage;
198
203
  private shouldRenderToolTrace;
199
204
  private hasActiveExtensionPrompt;
200
205
  private restoreEditorFocusIfPossible;
@@ -210,12 +215,22 @@ export declare class InteractiveMode {
210
215
  private showExtensionError;
211
216
  private setupKeyHandlers;
212
217
  private handleClipboardImagePaste;
218
+ private updateAttachmentsBar;
219
+ private deleteAttachment;
220
+ private handleAttachmentKeyNavigation;
221
+ /**
222
+ * Convert attachment files to ImageContent array for sending to the model.
223
+ * Reads each file, base64-encodes, and resizes.
224
+ */
225
+ private processAttachmentFiles;
213
226
  /**
214
227
  * Extract image file paths from text, read them as base64 ImageContent,
215
228
  * and return the cleaned text with image references plus the image array.
216
229
  */
217
230
  private extractImagesFromText;
218
231
  private setupEditorSubmitHandler;
232
+ private cleanupStaleClipboardFiles;
233
+ private cleanupClipboardImages;
219
234
  private subscribeToAgent;
220
235
  private addSessionNavigationBanner;
221
236
  private handleEvent;
@@ -1,10 +1,3 @@
1
- /**
2
- * [UPSTREAM]: Depends on agent-core, ai, tui, core/* (session, model, config, tools)
3
- * [SURFACE]: InteractiveMode class, runInteractiveMode()
4
- * [LOCUS]: modes/interactive/interactive-mode.ts - TUI orchestration hub
5
- * [COVENANT]: Change TUI behavior → update P2 modes/CLAUDE.md
6
- */
7
- import * as crypto from "node:crypto";
8
1
  import * as fs from "node:fs";
9
2
  import * as os from "node:os";
10
3
  import * as path from "node:path";
@@ -31,6 +24,7 @@ import { ensureTool } from "../../core/utils/tools-manager.js";
31
24
  import { detectSupportedImageMimeTypeFromFile } from "../../utils/mime.js";
32
25
  import { formatDimensionNote, resizeImage } from "../utils/image-resize.js";
33
26
  import { ArminComponent } from "./components/armin.js";
27
+ import { AttachmentsBarComponent } from "./components/attachments-bar.js";
34
28
  import { AssistantMessageComponent } from "./components/assistant-message.js";
35
29
  import { BashExecutionComponent } from "./components/bash-execution.js";
36
30
  import { BorderedLoader } from "./components/bordered-loader.js";
@@ -68,6 +62,8 @@ function isExpandable(obj) {
68
62
  }
69
63
  export class InteractiveMode {
70
64
  options;
65
+ static clipboardImageSeq = 0;
66
+ clipboardImageFiles = [];
71
67
  session;
72
68
  ui;
73
69
  chatContainer;
@@ -141,6 +137,11 @@ export class InteractiveMode {
141
137
  builtInHeader = undefined;
142
138
  // Custom header from extension (undefined = use built-in header)
143
139
  customHeader = undefined;
140
+ // Attachments state
141
+ attachments = [];
142
+ selectedAttachmentIndex = -1;
143
+ attachmentsContainer = undefined;
144
+ attachmentsBar = undefined;
144
145
  // Convenience accessors
145
146
  get agent() {
146
147
  return this.session.agent;
@@ -172,6 +173,8 @@ export class InteractiveMode {
172
173
  });
173
174
  this.editor = this.defaultEditor;
174
175
  this.editorContainer = new Container();
176
+ this.attachmentsContainer = new Container();
177
+ this.editorContainer.addChild(this.attachmentsContainer);
175
178
  this.editorContainer.addChild(this.editor);
176
179
  this.footerDataProvider = new FooterDataProvider(session.cwd);
177
180
  this.footer = new FooterComponent(session, this.footerDataProvider, this.settingsManager.getShowTokenStats());
@@ -255,6 +258,8 @@ export class InteractiveMode {
255
258
  async init() {
256
259
  if (this.isInitialized)
257
260
  return;
261
+ // Clean up stale clipboard image files from previous sessions
262
+ this.cleanupStaleClipboardFiles();
258
263
  // Do not show changelog on startup; version check will prompt to update CLI when newer version exists
259
264
  // Ensure fd and rg are available (downloads if missing, adds to PATH via getBinDir)
260
265
  // Both are needed: fd for autocomplete, rg for grep tool and bash commands
@@ -1345,11 +1350,6 @@ export class InteractiveMode {
1345
1350
  * Show a notification for extensions.
1346
1351
  */
1347
1352
  showExtensionNotify(message, type) {
1348
- if (type !== "error" &&
1349
- this.isMemoryTraceMessage(message) &&
1350
- !this.settingsManager.getShowMemoryTrace()) {
1351
- return;
1352
- }
1353
1353
  if (type === "error") {
1354
1354
  this.showError(message);
1355
1355
  }
@@ -1360,9 +1360,6 @@ export class InteractiveMode {
1360
1360
  this.showStatus(message);
1361
1361
  }
1362
1362
  }
1363
- isMemoryTraceMessage(message) {
1364
- return message.startsWith("NanoMem");
1365
- }
1366
1363
  shouldRenderToolTrace(toolName) {
1367
1364
  if (toolName.startsWith("nanomem_")) {
1368
1365
  return this.settingsManager.getShowMemoryTrace();
@@ -1544,6 +1541,9 @@ export class InteractiveMode {
1544
1541
  if (this.loadingAnimation) {
1545
1542
  this.restoreQueuedMessagesToEditor({ abort: true });
1546
1543
  }
1544
+ else if (this.session.isStreaming) {
1545
+ this.agent.abort();
1546
+ }
1547
1547
  else if (this.session.isBashRunning) {
1548
1548
  this.session.abortBash();
1549
1549
  }
@@ -1604,6 +1604,10 @@ export class InteractiveMode {
1604
1604
  this.defaultEditor.onPasteImage = () => {
1605
1605
  this.handleClipboardImagePaste();
1606
1606
  };
1607
+ // Handle attachment navigation keys (arrow keys, delete)
1608
+ this.defaultEditor.onAttachmentKey = (data) => {
1609
+ return this.handleAttachmentKeyNavigation(data);
1610
+ };
1607
1611
  }
1608
1612
  async handleClipboardImagePaste() {
1609
1613
  try {
@@ -1611,20 +1615,138 @@ export class InteractiveMode {
1611
1615
  if (!image) {
1612
1616
  return;
1613
1617
  }
1614
- // Write to temp file
1615
- const tmpDir = os.tmpdir();
1618
+ // Save to a dedicated _paste/ directory so the model finds ONLY
1619
+ // the clipboard image when browsing (avoids confusion with other
1620
+ // image files like IMG_*.JPG in the project root).
1616
1621
  const ext = extensionForImageMimeType(image.mimeType) ?? "png";
1617
- const fileName = `pi-clipboard-${crypto.randomUUID()}.${ext}`;
1618
- const filePath = path.join(tmpDir, fileName);
1622
+ const seq = ++InteractiveMode.clipboardImageSeq;
1623
+ const fileName = `image_${seq}.${ext}`;
1624
+ const pasteDir = path.join(this.session.cwd, "_paste");
1625
+ fs.mkdirSync(pasteDir, { recursive: true });
1626
+ const filePath = path.join(pasteDir, fileName);
1619
1627
  fs.writeFileSync(filePath, Buffer.from(image.bytes));
1620
- // Insert file path directly
1621
- this.editor.insertTextAtCursor?.(filePath);
1628
+ // Track for cleanup and add to attachments list
1629
+ this.clipboardImageFiles.push(filePath);
1630
+ this.attachments.push({ path: filePath, mimeType: image.mimeType });
1631
+ this.updateAttachmentsBar();
1622
1632
  this.ui.requestRender();
1623
1633
  }
1624
1634
  catch {
1625
1635
  // Silently ignore clipboard errors (may not have permission, etc.)
1626
1636
  }
1627
1637
  }
1638
+ updateAttachmentsBar() {
1639
+ if (!this.attachmentsContainer)
1640
+ return;
1641
+ this.attachmentsContainer.clear();
1642
+ if (this.attachments.length === 0) {
1643
+ this.attachmentsBar = undefined;
1644
+ this.editorContainer.removeChild(this.attachmentsContainer);
1645
+ return;
1646
+ }
1647
+ // Ensure attachmentsContainer is placed before the editor in the layout
1648
+ if (!this.editorContainer.children.includes(this.attachmentsContainer)) {
1649
+ const editorIdx = this.editorContainer.children.indexOf(this.editor);
1650
+ if (editorIdx >= 0) {
1651
+ this.editorContainer.children.splice(editorIdx, 0, this.attachmentsContainer);
1652
+ }
1653
+ else {
1654
+ this.editorContainer.addChild(this.attachmentsContainer);
1655
+ }
1656
+ }
1657
+ const themeName = this.settingsManager.getTheme();
1658
+ const theme = getThemeByName(themeName || "dark") ?? getThemeByName("dark");
1659
+ this.attachmentsBar = new AttachmentsBarComponent(this.attachments, this.selectedAttachmentIndex, theme);
1660
+ this.attachmentsContainer.addChild(this.attachmentsBar);
1661
+ }
1662
+ deleteAttachment(index) {
1663
+ if (index < 0 || index >= this.attachments.length)
1664
+ return;
1665
+ // Remove the attachment file
1666
+ const attachment = this.attachments[index];
1667
+ try {
1668
+ fs.unlinkSync(attachment.path);
1669
+ }
1670
+ catch {
1671
+ // Ignore file deletion errors
1672
+ }
1673
+ this.attachments.splice(index, 1);
1674
+ if (this.selectedAttachmentIndex >= this.attachments.length) {
1675
+ this.selectedAttachmentIndex = this.attachments.length - 1;
1676
+ }
1677
+ this.updateAttachmentsBar();
1678
+ this.ui.requestRender();
1679
+ }
1680
+ handleAttachmentKeyNavigation(data) {
1681
+ if (this.attachments.length === 0)
1682
+ return false;
1683
+ // Only intercept up/down arrows when multiple attachments need navigation.
1684
+ // With a single attachment, let the editor handle arrows for history browsing.
1685
+ if (this.attachments.length > 1) {
1686
+ if (matchesKey(data, "up")) {
1687
+ if (this.selectedAttachmentIndex < 0) {
1688
+ this.selectedAttachmentIndex = 0;
1689
+ }
1690
+ else if (this.selectedAttachmentIndex > 0) {
1691
+ this.selectedAttachmentIndex--;
1692
+ }
1693
+ this.updateAttachmentsBar();
1694
+ this.ui.requestRender();
1695
+ return true;
1696
+ }
1697
+ if (matchesKey(data, "down")) {
1698
+ if (this.selectedAttachmentIndex < 0) {
1699
+ this.selectedAttachmentIndex = 0;
1700
+ }
1701
+ else if (this.selectedAttachmentIndex < this.attachments.length - 1) {
1702
+ this.selectedAttachmentIndex++;
1703
+ }
1704
+ this.updateAttachmentsBar();
1705
+ this.ui.requestRender();
1706
+ return true;
1707
+ }
1708
+ }
1709
+ // Delete/backspace only removes attachment when one is explicitly selected
1710
+ if (this.selectedAttachmentIndex >= 0 &&
1711
+ (matchesKey(data, "delete") || matchesKey(data, "backspace"))) {
1712
+ this.deleteAttachment(this.selectedAttachmentIndex);
1713
+ return true;
1714
+ }
1715
+ return false;
1716
+ }
1717
+ /**
1718
+ * Convert attachment files to ImageContent array for sending to the model.
1719
+ * Reads each file, base64-encodes, and resizes.
1720
+ */
1721
+ async processAttachmentFiles(attachments) {
1722
+ const result = [];
1723
+ for (const attachment of attachments) {
1724
+ try {
1725
+ if (!fs.existsSync(attachment.path))
1726
+ continue;
1727
+ const mimeType = attachment.mimeType ??
1728
+ (await detectSupportedImageMimeTypeFromFile(attachment.path));
1729
+ if (!mimeType)
1730
+ continue;
1731
+ const content = fs.readFileSync(attachment.path);
1732
+ const base64Content = content.toString("base64");
1733
+ const resized = await resizeImage({
1734
+ type: "image",
1735
+ data: base64Content,
1736
+ mimeType,
1737
+ });
1738
+ result.push({
1739
+ type: "image",
1740
+ mimeType: resized.mimeType,
1741
+ data: resized.data,
1742
+ });
1743
+ }
1744
+ catch {
1745
+ // Skip unreadable attachment files
1746
+ }
1747
+ }
1748
+ return result;
1749
+ }
1628
1750
  /**
1629
1751
  * Extract image file paths from text, read them as base64 ImageContent,
1630
1752
  * and return the cleaned text with image references plus the image array.
@@ -1886,11 +2008,34 @@ export class InteractiveMode {
1886
2008
  this.editor.addToHistory?.(text);
1887
2009
  this.editor.setText("");
1888
2010
  const steerResult = await this.extractImagesFromText(text);
1889
- await this.session.prompt(steerResult.text, {
2011
+ const steerImages = steerResult.images;
2012
+ let steerAttachmentPaths = [];
2013
+ if (this.attachments.length > 0) {
2014
+ const pendingAttachments = this.attachments.splice(0);
2015
+ this.selectedAttachmentIndex = -1;
2016
+ this.updateAttachmentsBar();
2017
+ this.ui.requestRender();
2018
+ steerAttachmentPaths = pendingAttachments.map((a) => a.path);
2019
+ }
2020
+ // Drop images if model doesn't support them
2021
+ const steerModel = this.session.model;
2022
+ if ((steerImages.length > 0 || steerAttachmentPaths.length > 0) &&
2023
+ steerModel &&
2024
+ !steerModel.input.includes("image")) {
2025
+ steerImages.length = 0;
2026
+ steerAttachmentPaths = [];
2027
+ }
2028
+ let steerPromptText = steerResult.text;
2029
+ if (steerAttachmentPaths.length > 0) {
2030
+ const cwd = this.session.cwd;
2031
+ const refs = steerAttachmentPaths
2032
+ .map((p) => `@${path.relative(cwd, p).replace(/\\/g, "/")}`)
2033
+ .join(" ");
2034
+ steerPromptText = refs + " " + steerPromptText;
2035
+ }
2036
+ await this.session.prompt(steerPromptText, {
1890
2037
  streamingBehavior: "steer",
1891
- images: steerResult.images.length > 0
1892
- ? steerResult.images
1893
- : undefined,
2038
+ images: steerImages.length > 0 ? steerImages : undefined,
1894
2039
  });
1895
2040
  this.updatePendingMessagesDisplay();
1896
2041
  this.ui.requestRender();
@@ -1908,6 +2053,37 @@ export class InteractiveMode {
1908
2053
  this.editor.setText("");
1909
2054
  // Extract images from clipboard-pasted file paths in the text
1910
2055
  const { text: processedText, images } = await this.extractImagesFromText(text);
2056
+ // Collect and clear pending attachments upfront (ensures cleanup even on error).
2057
+ // Clipboard images are saved under .pi/images/ so models can read them via
2058
+ // file tools — this is more reliable than inline base64 (some models like
2059
+ // Kimi K2.5 re-map inline images to internal server paths that break MCP).
2060
+ let attachmentPaths = [];
2061
+ if (this.attachments.length > 0) {
2062
+ const pendingAttachments = this.attachments.splice(0);
2063
+ this.selectedAttachmentIndex = -1;
2064
+ this.updateAttachmentsBar();
2065
+ this.ui.requestRender();
2066
+ attachmentPaths = pendingAttachments.map((a) => a.path);
2067
+ }
2068
+ // Check model image support; warn and drop images if not supported
2069
+ if (images.length > 0 || attachmentPaths.length > 0) {
2070
+ const currentModel = this.session.model;
2071
+ if (currentModel && !currentModel.input.includes("image")) {
2072
+ this.showWarning(`Model "${currentModel.name}" does not support image input. Images have been removed from this message.`);
2073
+ images.length = 0;
2074
+ attachmentPaths = [];
2075
+ }
2076
+ }
2077
+ // Build image references using the relative path (_paste/image_N.ext).
2078
+ // Prepended before user text so the model processes it first.
2079
+ let imageRefPrefix = "";
2080
+ if (attachmentPaths.length > 0) {
2081
+ const cwd = this.session.cwd;
2082
+ const refs = attachmentPaths
2083
+ .map((p) => `@${path.relative(cwd, p).replace(/\\/g, "/")}`)
2084
+ .join(" ");
2085
+ imageRefPrefix = refs + " ";
2086
+ }
1911
2087
  if (!processedText.startsWith("/")) {
1912
2088
  const displayContent = [
1913
2089
  { type: "text", text: processedText },
@@ -1926,7 +2102,10 @@ export class InteractiveMode {
1926
2102
  try {
1927
2103
  // Clear persona switch flag - interview should now run normally for subsequent messages
1928
2104
  delete process.env.PI_JUST_SWITCHED_PERSONA;
1929
- await this.session.prompt(processedText, {
2105
+ const promptText = imageRefPrefix
2106
+ ? imageRefPrefix + processedText
2107
+ : processedText;
2108
+ await this.session.prompt(promptText, {
1930
2109
  images: images.length > 0 ? images : undefined,
1931
2110
  });
1932
2111
  }
@@ -1941,8 +2120,67 @@ export class InteractiveMode {
1941
2120
  }
1942
2121
  this.updatePendingMessagesDisplay();
1943
2122
  this.ui.requestRender();
2123
+ // Clean up temporary clipboard image files from project root
2124
+ this.cleanupClipboardImages();
1944
2125
  };
1945
2126
  }
2127
+ cleanupStaleClipboardFiles() {
2128
+ try {
2129
+ const cwd = this.session.cwd;
2130
+ // Clean old-format root files (_clipboard_*.png)
2131
+ for (const entry of fs.readdirSync(cwd)) {
2132
+ if (/^_clipboard_\d+\.\w+$/.test(entry)) {
2133
+ try {
2134
+ fs.unlinkSync(path.join(cwd, entry));
2135
+ }
2136
+ catch { /* best-effort */ }
2137
+ }
2138
+ }
2139
+ // Clean _paste/ directory from previous sessions
2140
+ const pasteDir = path.join(cwd, "_paste");
2141
+ if (fs.existsSync(pasteDir)) {
2142
+ for (const entry of fs.readdirSync(pasteDir)) {
2143
+ try {
2144
+ fs.unlinkSync(path.join(pasteDir, entry));
2145
+ }
2146
+ catch { /* best-effort */ }
2147
+ }
2148
+ try {
2149
+ fs.rmdirSync(pasteDir);
2150
+ }
2151
+ catch { /* best-effort */ }
2152
+ }
2153
+ }
2154
+ catch {
2155
+ // Ignore errors during cleanup
2156
+ }
2157
+ }
2158
+ cleanupClipboardImages() {
2159
+ for (const filePath of this.clipboardImageFiles) {
2160
+ try {
2161
+ if (fs.existsSync(filePath)) {
2162
+ fs.unlinkSync(filePath);
2163
+ }
2164
+ }
2165
+ catch {
2166
+ // Best-effort cleanup
2167
+ }
2168
+ }
2169
+ this.clipboardImageFiles = [];
2170
+ // Remove _paste/ directory if empty
2171
+ try {
2172
+ const pasteDir = path.join(this.session.cwd, "_paste");
2173
+ if (fs.existsSync(pasteDir)) {
2174
+ const remaining = fs.readdirSync(pasteDir);
2175
+ if (remaining.length === 0) {
2176
+ fs.rmdirSync(pasteDir);
2177
+ }
2178
+ }
2179
+ }
2180
+ catch {
2181
+ // Best-effort
2182
+ }
2183
+ }
1946
2184
  subscribeToAgent() {
1947
2185
  this.unsubscribe = this.session.subscribe(async (event) => {
1948
2186
  await this.handleEvent(event);
@@ -3342,7 +3580,31 @@ export class InteractiveMode {
3342
3580
  }, () => {
3343
3581
  done();
3344
3582
  this.ui.requestRender();
3345
- }, initialSearchInput, filterByProvider);
3583
+ }, initialSearchInput, filterByProvider, () => {
3584
+ void (async () => {
3585
+ done();
3586
+ const modelId = await this.showExtensionInput("Add OpenRouter model", "Model id (e.g. x-ai/grok-4.20)");
3587
+ if (!modelId?.trim()) {
3588
+ this.showModelSelector(initialSearchInput, filterByProvider);
3589
+ return;
3590
+ }
3591
+ const nameInput = await this.showExtensionInput("Display name (optional)", "Leave empty to use model id", { initialValue: modelId.trim() });
3592
+ if (nameInput === undefined) {
3593
+ this.showModelSelector(initialSearchInput, filterByProvider);
3594
+ return;
3595
+ }
3596
+ try {
3597
+ this.session.modelRegistry.appendOpenRouterModel(modelId.trim(), {
3598
+ name: nameInput.trim() || undefined,
3599
+ });
3600
+ this.showStatus(`Added OpenRouter model ${modelId.trim()}`);
3601
+ }
3602
+ catch (error) {
3603
+ this.showError(error instanceof Error ? error.message : String(error));
3604
+ }
3605
+ this.showModelSelector(initialSearchInput, filterByProvider);
3606
+ })();
3607
+ });
3346
3608
  return { component: selector, focus: selector };
3347
3609
  });
3348
3610
  }
@@ -75,6 +75,12 @@ export declare const NANOPENCIL_DEFAULT_MODELS_JSON: {
75
75
  readonly baseUrl: "https://api.minimaxi.com/anthropic";
76
76
  readonly api: "openai-completions";
77
77
  readonly models: readonly [{
78
+ readonly id: "MiniMax-M2.7";
79
+ readonly name: "MiniMax M2.7";
80
+ readonly input: readonly ["text"];
81
+ readonly contextWindow: 204800;
82
+ readonly maxTokens: 65536;
83
+ }, {
78
84
  readonly id: "MiniMax-M2.5";
79
85
  readonly name: "MiniMax M2.5";
80
86
  readonly input: readonly ["text"];
@@ -99,6 +99,13 @@ export const NANOPENCIL_DEFAULT_MODELS_JSON = {
99
99
  baseUrl: MINIMAX_CODING_BASE_URL,
100
100
  api: "openai-completions",
101
101
  models: [
102
+ {
103
+ id: "MiniMax-M2.7",
104
+ name: "MiniMax M2.7",
105
+ input: ["text"],
106
+ contextWindow: 204800,
107
+ maxTokens: 65536,
108
+ },
102
109
  {
103
110
  id: "MiniMax-M2.5",
104
111
  name: "MiniMax M2.5",
@@ -461,6 +468,58 @@ function mergeNanopencilModelsIfNeeded(modelsPath) {
461
468
  writeFileSync(modelsPath, JSON.stringify(data, null, 2), "utf-8");
462
469
  }
463
470
  }
471
+ // 合并 minimax-coding:不存在则添加默认配置,存在则补充默认模型
472
+ const minimaxProvider = data.providers[NANOPENCIL_MINIMAX_CODING_PROVIDER];
473
+ const minimaxConfig = NANOPENCIL_DEFAULT_MODELS_JSON.providers[NANOPENCIL_MINIMAX_CODING_PROVIDER];
474
+ if (!minimaxProvider) {
475
+ data.providers[NANOPENCIL_MINIMAX_CODING_PROVIDER] = {
476
+ baseUrl: minimaxConfig.baseUrl,
477
+ api: minimaxConfig.api,
478
+ models: DEFAULT_MINIMAX_MODELS.map((m) => ({ ...m })),
479
+ };
480
+ writeFileSync(modelsPath, JSON.stringify(data, null, 2), "utf-8");
481
+ }
482
+ else {
483
+ const minimaxModels = (Array.isArray(minimaxProvider.models) ? [...minimaxProvider.models] : []);
484
+ const minimaxById = new Map();
485
+ for (const m of minimaxModels) {
486
+ const id = m?.id;
487
+ if (typeof id === "string")
488
+ minimaxById.set(id, m);
489
+ }
490
+ let minimaxChanged = false;
491
+ for (const def of DEFAULT_MINIMAX_MODELS) {
492
+ const id = def.id;
493
+ const existing = minimaxById.get(id);
494
+ if (!existing) {
495
+ minimaxModels.push({ ...def });
496
+ minimaxById.set(id, minimaxModels[minimaxModels.length - 1]);
497
+ minimaxChanged = true;
498
+ }
499
+ else {
500
+ if (existing.contextWindow !== def.contextWindow) {
501
+ existing.contextWindow = def.contextWindow;
502
+ minimaxChanged = true;
503
+ }
504
+ if (existing.maxTokens !== def.maxTokens) {
505
+ existing.maxTokens = def.maxTokens;
506
+ minimaxChanged = true;
507
+ }
508
+ if (JSON.stringify(existing.input) !== JSON.stringify(def.input)) {
509
+ existing.input = def.input;
510
+ minimaxChanged = true;
511
+ }
512
+ if (existing.name !== def.name) {
513
+ existing.name = def.name;
514
+ minimaxChanged = true;
515
+ }
516
+ }
517
+ }
518
+ if (minimaxChanged) {
519
+ data.providers[NANOPENCIL_MINIMAX_CODING_PROVIDER].models = minimaxModels;
520
+ writeFileSync(modelsPath, JSON.stringify(data, null, 2), "utf-8");
521
+ }
522
+ }
464
523
  // 合并 ollama:不存在则添加默认配置,存在则补充默认模型
465
524
  const ollamaProvider = data.providers[NANOPENCIL_OLLAMA_PROVIDER];
466
525
  const ollamaConfig = NANOPENCIL_DEFAULT_MODELS_JSON.providers[NANOPENCIL_OLLAMA_PROVIDER];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pencil-agent/nano-pencil",
3
- "version": "1.11.32",
3
+ "version": "1.11.33",
4
4
  "description": "CLI writing agent with read, bash, edit, write tools and session management. Based on pi; supports DashScope Coding Plan. Soul enabled by default for AI personality evolution.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -29,6 +29,7 @@
29
29
  "bundle:packages": "node scripts/bundle-deps.js",
30
30
  "watch": "tsc --watch",
31
31
  "test:presence": "node --test --import tsx test/presence-opening.test.ts",
32
+ "test:interactive-memory-notify": "node --test --import tsx test/interactive-memory-notify.test.ts",
32
33
  "start": "npx cross-env NODE_ENV=production node --no-deprecation dist/cli.js",
33
34
  "prepublishOnly": "npm run build",
34
35
  "changelog": "node scripts/generate-changelog.js",