pi-ui-extend 0.1.34 → 0.1.36

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 (73) hide show
  1. package/README.md +20 -0
  2. package/dist/app/app.d.ts +1 -0
  3. package/dist/app/app.js +12 -2
  4. package/dist/app/commands/command-host.d.ts +1 -0
  5. package/dist/app/commands/command-model-actions.d.ts +1 -0
  6. package/dist/app/commands/command-model-actions.js +32 -0
  7. package/dist/app/commands/command-navigation-actions.js +3 -0
  8. package/dist/app/commands/command-session-actions.js +2 -0
  9. package/dist/app/constants.d.ts +2 -1
  10. package/dist/app/constants.js +6 -1
  11. package/dist/app/extensions/extension-actions-controller.d.ts +1 -0
  12. package/dist/app/extensions/extension-actions-controller.js +4 -0
  13. package/dist/app/input/input-controller.d.ts +5 -1
  14. package/dist/app/input/input-controller.js +122 -16
  15. package/dist/app/input/input-paste-handler.js +3 -1
  16. package/dist/app/input/terminal-edit-shortcuts.d.ts +21 -0
  17. package/dist/app/input/terminal-edit-shortcuts.js +92 -16
  18. package/dist/app/popup/popup-action-controller.d.ts +1 -0
  19. package/dist/app/popup/popup-action-controller.js +1 -0
  20. package/dist/app/rendering/conversation-entry-renderer.d.ts +1 -0
  21. package/dist/app/rendering/conversation-entry-renderer.js +1 -1
  22. package/dist/app/rendering/conversation-tool-renderer.d.ts +1 -0
  23. package/dist/app/rendering/conversation-tool-renderer.js +21 -0
  24. package/dist/app/rendering/conversation-viewport.d.ts +3 -0
  25. package/dist/app/rendering/conversation-viewport.js +41 -5
  26. package/dist/app/rendering/editor-layout-renderer.js +3 -2
  27. package/dist/app/rendering/editor-panels.js +27 -10
  28. package/dist/app/runtime.d.ts +1 -0
  29. package/dist/app/runtime.js +33 -14
  30. package/dist/app/session/session-event-controller.d.ts +7 -0
  31. package/dist/app/session/session-event-controller.js +78 -0
  32. package/dist/app/session/session-lifecycle-controller.d.ts +1 -0
  33. package/dist/app/session/session-lifecycle-controller.js +7 -0
  34. package/dist/app/session/tabs-controller.d.ts +1 -0
  35. package/dist/app/session/tabs-controller.js +4 -1
  36. package/dist/app/subagents/subagents-widget-controller.d.ts +10 -2
  37. package/dist/app/subagents/subagents-widget-controller.js +141 -70
  38. package/dist/app/terminal/terminal-controller.d.ts +10 -0
  39. package/dist/app/terminal/terminal-controller.js +91 -2
  40. package/dist/app/todo/todo-model.js +2 -0
  41. package/dist/app/todo/todo-widget-controller.d.ts +2 -0
  42. package/dist/app/todo/todo-widget-controller.js +17 -7
  43. package/dist/app/types.d.ts +4 -0
  44. package/dist/app/workspace/workspace-actions-controller.d.ts +1 -0
  45. package/dist/app/workspace/workspace-actions-controller.js +1 -0
  46. package/dist/bundled-extensions/question/tui.js +8 -1
  47. package/dist/bundled-extensions/session-title/index.js +65 -14
  48. package/dist/input-editor-files.js +23 -4
  49. package/dist/markdown-format.d.ts +4 -1
  50. package/dist/markdown-format.js +76 -9
  51. package/external/pi-tools-suite/README.md +71 -1
  52. package/external/pi-tools-suite/package.json +5 -5
  53. package/external/pi-tools-suite/src/async-subagents/commands.ts +12 -6
  54. package/external/pi-tools-suite/src/async-subagents/index.ts +133 -37
  55. package/external/pi-tools-suite/src/context-usage.ts +6 -1
  56. package/external/pi-tools-suite/src/dcp/commands.ts +3 -2
  57. package/external/pi-tools-suite/src/dcp/compress-tool.ts +9 -4
  58. package/external/pi-tools-suite/src/dcp/config.ts +142 -6
  59. package/external/pi-tools-suite/src/dcp/index.ts +20 -8
  60. package/external/pi-tools-suite/src/dcp/prompts.ts +17 -9
  61. package/external/pi-tools-suite/src/dcp/pruner-candidates.ts +59 -15
  62. package/external/pi-tools-suite/src/dcp/pruner-metadata.ts +6 -8
  63. package/external/pi-tools-suite/src/dcp/pruner-nudge.ts +3 -3
  64. package/external/pi-tools-suite/src/default-pi-tools-suite-config.ts +51 -1
  65. package/external/pi-tools-suite/src/glm-coding-discipline/index.ts +16 -11
  66. package/external/pi-tools-suite/src/model-tools/index.ts +24 -12
  67. package/external/pi-tools-suite/src/prompt-commands/index.ts +11 -2
  68. package/external/pi-tools-suite/src/telegram-mirror/index.ts +66 -27
  69. package/external/pi-tools-suite/src/todo/index.ts +87 -16
  70. package/external/pi-tools-suite/src/todo/state/store.ts +41 -10
  71. package/external/pi-tools-suite/src/todo/todo.ts +49 -6
  72. package/external/pi-tools-suite/src/tool-descriptions.ts +4 -4
  73. package/package.json +7 -5
@@ -1,10 +1,14 @@
1
1
  import { ANSI_RESET } from "../../theme.js";
2
- import { DISABLE_BRACKETED_PASTE, DISABLE_TERMINAL_KEY_REPORTING, DISABLE_TERMINAL_WRAP, CLEAR_TERMINAL, ENABLE_BRACKETED_PASTE, ENABLE_TERMINAL_KEY_REPORTING, ENABLE_TERMINAL_WRAP, HIDE_CURSOR, RESET_TERMINAL_VIEWPORT_STATE, RUNTIME_DISPOSE_GRACE_MS, SHOW_CURSOR, } from "../constants.js";
2
+ import { DISABLE_BRACKETED_PASTE, DISABLE_TERMINAL_KEY_REPORTING, DISABLE_TERMINAL_WRAP, CLEAR_TERMINAL, ENABLE_BRACKETED_PASTE, ENABLE_TERMINAL_MODIFY_OTHER_KEYS, ENABLE_TERMINAL_KEY_REPORTING, ENABLE_TERMINAL_WRAP, HIDE_CURSOR, RESET_TERMINAL_VIEWPORT_STATE, RUNTIME_DISPOSE_GRACE_MS, SHOW_CURSOR, } from "../constants.js";
3
3
  export class AppTerminalController {
4
4
  host;
5
5
  terminalEnabled = false;
6
6
  interactiveSuspended = false;
7
7
  stopPromise;
8
+ keyboardProtocolNegotiationBuffer = "";
9
+ keyboardProtocolBufferFlushTimer;
10
+ kittyProtocolActive = false;
11
+ modifyOtherKeysActive = false;
8
12
  enterInteractiveSequence = `${ANSI_RESET}${RESET_TERMINAL_VIEWPORT_STATE}${CLEAR_TERMINAL}\x1b[?1049h${RESET_TERMINAL_VIEWPORT_STATE}${CLEAR_TERMINAL}${ENABLE_TERMINAL_KEY_REPORTING}${ENABLE_BRACKETED_PASTE}${DISABLE_TERMINAL_WRAP}\x1b[?1002h\x1b[?1006h${HIDE_CURSOR}`;
9
13
  exitInteractiveSequence = `${ANSI_RESET}${RESET_TERMINAL_VIEWPORT_STATE}${DISABLE_TERMINAL_KEY_REPORTING}${DISABLE_BRACKETED_PASTE}${ENABLE_TERMINAL_WRAP}\x1b[?1006l\x1b[?1002l\x1b[?1049l${SHOW_CURSOR}`;
10
14
  constructor(host) {
@@ -17,6 +21,7 @@ export class AppTerminalController {
17
21
  if (this.terminalEnabled)
18
22
  return;
19
23
  this.terminalEnabled = true;
24
+ this.beginKeyboardProtocolNegotiation();
20
25
  process.stdin.setRawMode(true);
21
26
  process.stdin.resume();
22
27
  process.stdin.on("data", this.onInputData);
@@ -66,6 +71,7 @@ export class AppTerminalController {
66
71
  restoreTerminal = () => {
67
72
  if (!this.terminalEnabled)
68
73
  return;
74
+ this.clearKeyboardProtocolNegotiationBuffer();
69
75
  this.terminalEnabled = false;
70
76
  this.interactiveSuspended = false;
71
77
  process.stdout.write(this.exitInteractiveSequence);
@@ -87,6 +93,7 @@ export class AppTerminalController {
87
93
  if (!this.terminalEnabled || !this.interactiveSuspended)
88
94
  return;
89
95
  this.interactiveSuspended = false;
96
+ this.beginKeyboardProtocolNegotiation();
90
97
  if (process.stdin.isTTY)
91
98
  process.stdin.setRawMode(true);
92
99
  process.stdin.resume();
@@ -99,6 +106,7 @@ export class AppTerminalController {
99
106
  async stopInternal() {
100
107
  if (!this.host.isRunning())
101
108
  return;
109
+ this.clearKeyboardProtocolNegotiationBuffer();
102
110
  this.host.setRunning(false);
103
111
  this.host.closeSdkMenuForStop();
104
112
  this.host.clearToastTimers();
@@ -133,6 +141,87 @@ export class AppTerminalController {
133
141
  this.host.render();
134
142
  };
135
143
  onInputData = (chunk) => {
136
- this.host.handleInputChunk(chunk);
144
+ const input = this.filterKeyboardProtocolNegotiationInput(chunk.toString("utf8"));
145
+ if (input)
146
+ this.host.handleInputChunk(Buffer.from(input, "utf8"));
137
147
  };
148
+ beginKeyboardProtocolNegotiation() {
149
+ this.clearKeyboardProtocolNegotiationBuffer();
150
+ this.kittyProtocolActive = false;
151
+ this.modifyOtherKeysActive = false;
152
+ }
153
+ filterKeyboardProtocolNegotiationInput(data) {
154
+ let input = this.keyboardProtocolNegotiationBuffer + data;
155
+ this.clearKeyboardProtocolNegotiationBuffer();
156
+ let output = "";
157
+ while (input.length > 0) {
158
+ const response = readKeyboardProtocolNegotiationResponse(input);
159
+ if (response.kind === "complete") {
160
+ this.handleKeyboardProtocolNegotiationResponse(response.response);
161
+ input = input.slice(response.length);
162
+ continue;
163
+ }
164
+ if (response.kind === "pending") {
165
+ this.setKeyboardProtocolNegotiationBuffer(input);
166
+ break;
167
+ }
168
+ output += input[0];
169
+ input = input.slice(1);
170
+ }
171
+ return output;
172
+ }
173
+ handleKeyboardProtocolNegotiationResponse(response) {
174
+ if (response.type === "kitty-flags") {
175
+ if (response.flags !== 0) {
176
+ this.kittyProtocolActive = true;
177
+ this.modifyOtherKeysActive = false;
178
+ }
179
+ else {
180
+ this.enableModifyOtherKeysFallback();
181
+ }
182
+ return;
183
+ }
184
+ if (!this.kittyProtocolActive)
185
+ this.enableModifyOtherKeysFallback();
186
+ }
187
+ enableModifyOtherKeysFallback() {
188
+ if (this.kittyProtocolActive || this.modifyOtherKeysActive)
189
+ return;
190
+ process.stdout.write(ENABLE_TERMINAL_MODIFY_OTHER_KEYS);
191
+ this.modifyOtherKeysActive = true;
192
+ }
193
+ setKeyboardProtocolNegotiationBuffer(data) {
194
+ this.clearKeyboardProtocolNegotiationBuffer();
195
+ this.keyboardProtocolNegotiationBuffer = data;
196
+ this.keyboardProtocolBufferFlushTimer = setTimeout(() => {
197
+ this.keyboardProtocolBufferFlushTimer = undefined;
198
+ const buffered = this.keyboardProtocolNegotiationBuffer;
199
+ this.keyboardProtocolNegotiationBuffer = "";
200
+ if (buffered)
201
+ this.host.handleInputChunk(Buffer.from(buffered, "utf8"));
202
+ }, 150);
203
+ }
204
+ clearKeyboardProtocolNegotiationBuffer() {
205
+ if (this.keyboardProtocolBufferFlushTimer)
206
+ clearTimeout(this.keyboardProtocolBufferFlushTimer);
207
+ this.keyboardProtocolBufferFlushTimer = undefined;
208
+ this.keyboardProtocolNegotiationBuffer = "";
209
+ }
210
+ }
211
+ function readKeyboardProtocolNegotiationResponse(input) {
212
+ const kittyFlags = /^\x1b\[\?(\d+)u/.exec(input);
213
+ if (kittyFlags) {
214
+ return {
215
+ kind: "complete",
216
+ response: { type: "kitty-flags", flags: Number.parseInt(kittyFlags[1] ?? "0", 10) },
217
+ length: kittyFlags[0].length,
218
+ };
219
+ }
220
+ const deviceAttributes = /^\x1b\[\?[\d;]*c/.exec(input);
221
+ if (deviceAttributes) {
222
+ return { kind: "complete", response: { type: "device-attributes" }, length: deviceAttributes[0].length };
223
+ }
224
+ if (input === "\x1b[" || /^\x1b\[\?[\d;]*$/.test(input))
225
+ return { kind: "pending" };
226
+ return { kind: "none" };
138
227
  }
@@ -59,6 +59,8 @@ export function isTodoLiveStateEvent(value) {
59
59
  return false;
60
60
  if (value.sessionFile !== undefined && typeof value.sessionFile !== "string")
61
61
  return false;
62
+ if (value.sessionId !== undefined && typeof value.sessionId !== "string")
63
+ return false;
62
64
  return typeof value.checkedAt === "number" && Number.isFinite(value.checkedAt);
63
65
  }
64
66
  export function todoStatusIcon(status) {
@@ -1,6 +1,7 @@
1
1
  import type { TodoDetails } from "../types.js";
2
2
  export type TodoWidgetControllerHost = {
3
3
  sessionFile?(): string | undefined;
4
+ sessionId?(): string | undefined;
4
5
  isRunning(): boolean;
5
6
  render(): void;
6
7
  };
@@ -17,5 +18,6 @@ export declare class AppTodoWidgetController {
17
18
  private updateDetailsForCurrentSession;
18
19
  private updateDetailsForSession;
19
20
  private currentSessionDetails;
21
+ private currentSessionKey;
20
22
  private sessionKey;
21
23
  }
@@ -25,17 +25,21 @@ export class AppTodoWidgetController {
25
25
  observeLiveState(data) {
26
26
  if (!isTodoLiveStateEvent(data))
27
27
  return;
28
- this.updateDetailsForSession(data.sessionFile ?? this.host.sessionFile?.(), this.visibleDetails(data.details));
28
+ const sessionFile = data.sessionFile;
29
+ const sessionId = data.sessionId;
30
+ if (!sessionFile && !sessionId && this.currentSessionKey())
31
+ return;
32
+ this.updateDetailsForSession(sessionFile, sessionId, this.visibleDetails(data.details));
29
33
  }
30
34
  visibleDetails(details) {
31
35
  return hasOpenTodoTasks(details) ? details : undefined;
32
36
  }
33
37
  updateDetailsForCurrentSession(next, options = {}) {
34
- this.updateDetailsForSession(this.host.sessionFile?.(), next, options);
38
+ this.updateDetailsForSession(this.host.sessionFile?.(), this.host.sessionId?.(), next, options);
35
39
  }
36
- updateDetailsForSession(sessionFile, next, options = {}) {
40
+ updateDetailsForSession(sessionFile, sessionId, next, options = {}) {
37
41
  const previous = stringifyUnknown(this.currentSessionDetails());
38
- const key = this.sessionKey(sessionFile);
42
+ const key = this.sessionKey(sessionFile, sessionId);
39
43
  if (key) {
40
44
  if (next)
41
45
  this.detailsBySessionFile.set(key, next);
@@ -50,10 +54,16 @@ export class AppTodoWidgetController {
50
54
  this.host.render();
51
55
  }
52
56
  currentSessionDetails() {
53
- const key = this.sessionKey(this.host.sessionFile?.());
57
+ const key = this.currentSessionKey();
54
58
  return key ? this.detailsBySessionFile.get(key) : this.unscopedDetails;
55
59
  }
56
- sessionKey(sessionFile) {
57
- return sessionFile ? resolve(sessionFile) : undefined;
60
+ currentSessionKey() {
61
+ return this.sessionKey(this.host.sessionFile?.(), this.host.sessionId?.());
62
+ }
63
+ sessionKey(sessionFile, sessionId) {
64
+ if (sessionFile)
65
+ return `file:${resolve(sessionFile)}`;
66
+ const normalizedSessionId = sessionId?.trim();
67
+ return normalizedSessionId ? `id:${normalizedSessionId}` : undefined;
58
68
  }
59
69
  }
@@ -74,6 +74,8 @@ export type Entry = {
74
74
  expanded: boolean;
75
75
  status: "running" | "done";
76
76
  level?: string;
77
+ startedAt?: number;
78
+ finishedAt?: number;
77
79
  } | {
78
80
  id: string;
79
81
  kind: "tool";
@@ -125,6 +127,7 @@ export type TodoLiveStateEvent = {
125
127
  version: 1;
126
128
  details: TodoDetails;
127
129
  sessionFile?: string;
130
+ sessionId?: string;
128
131
  checkedAt: number;
129
132
  };
130
133
  export type SubagentStatus = (typeof SUBAGENT_STATUSES)[number];
@@ -193,6 +196,7 @@ export type SubagentRegistryAgent = {
193
196
  updatedAt: string;
194
197
  };
195
198
  export type SubagentsWidgetState = {
199
+ runs?: SubagentLiveStateRun[];
196
200
  runDir: string;
197
201
  agents: SubagentAgentState[];
198
202
  tasks?: SubagentTaskPreview[];
@@ -4,6 +4,7 @@ import { type WorkspaceMutation, type WorkspaceMutationFromToolInput, type Works
4
4
  export type AppWorkspaceActionsControllerHost = {
5
5
  readonly entries: Entry[];
6
6
  runtime(): AgentSessionRuntime | undefined;
7
+ awaitCurrentSessionExtensions(runtime?: AgentSessionRuntime): Promise<void>;
7
8
  findUserEntry(entryId: string): Extract<Entry, {
8
9
  kind: "user";
9
10
  }> | undefined;
@@ -94,6 +94,7 @@ export class AppWorkspaceActionsController {
94
94
  throw new Error("Session entry for this message is not available yet");
95
95
  this.host.setStatus("forking session");
96
96
  this.host.render();
97
+ await this.host.awaitCurrentSessionExtensions(runtime);
97
98
  const result = await runtime.fork(sessionEntryId);
98
99
  if (result.cancelled) {
99
100
  this.host.addEntry({ id: createId("system"), kind: "system", text: "Fork cancelled." });
@@ -60,6 +60,12 @@ function wrapLine(line, width) {
60
60
  lines.push(current.trimEnd());
61
61
  return lines.length > 0 ? lines : [""];
62
62
  }
63
+ function wrapIndentedLine(line, width) {
64
+ const indent = line.match(/^\s*/)?.[0] ?? "";
65
+ const content = line.slice(indent.length);
66
+ const contentWidth = Math.max(1, width - indent.length);
67
+ return wrapLine(content, contentWidth).map((wrapped) => `${indent}${wrapped}`);
68
+ }
63
69
  function stripAnsi(text) {
64
70
  return text.replace(/\u001b\[[0-9;]*m/g, "");
65
71
  }
@@ -191,7 +197,8 @@ export async function runQuestionnaire(questions, ctx) {
191
197
  clickZones.push({ ...zone, row, startColumn: 1, endColumn: width + 1 });
192
198
  }
193
199
  function renderMutedLine(add, text, width) {
194
- add(paintLine(theme, text, width, { foreground: "muted" }));
200
+ for (const line of wrapIndentedLine(text, width))
201
+ add(paintLine(theme, line, width, { foreground: "muted" }));
195
202
  }
196
203
  function moveToQuestion(index) {
197
204
  captureCustomDraft();
@@ -4,6 +4,24 @@ import { loadSessionTitleConfig } from "./config.js";
4
4
  import { fallbackSessionTitleFromInput, firstUserMessageText as firstUserMessageTextFromEntries, generateSessionTitle, sessionTitleModelRefs, } from "./title-generation.js";
5
5
  export { fallbackSessionTitleFromInput, generateSessionTitle, sessionTitleModelRefs, sanitizeSessionTitle } from "./title-generation.js";
6
6
  const DEFAULT_TERMINAL_TITLE = "pi";
7
+ function isStaleExtensionContextError(error) {
8
+ if (!(error instanceof Error))
9
+ return false;
10
+ return /ctx is stale|stale ctx|stale after session replacement|stale after.*reload/i.test(error.message);
11
+ }
12
+ function ignoreStaleExtensionContextError(error) {
13
+ if (!isStaleExtensionContextError(error))
14
+ throw error;
15
+ }
16
+ function staleSafe(callback) {
17
+ try {
18
+ return callback();
19
+ }
20
+ catch (error) {
21
+ ignoreStaleExtensionContextError(error);
22
+ return undefined;
23
+ }
24
+ }
7
25
  function imageAttachmentLabel(images) {
8
26
  if (images.length === 0)
9
27
  return undefined;
@@ -76,14 +94,23 @@ export default function sessionTitle(pi) {
76
94
  clearTimeout(timer);
77
95
  refreshTimers.clear();
78
96
  }
97
+ function safeCtxCall(callback) {
98
+ return staleSafe(callback);
99
+ }
100
+ function safePiCall(callback) {
101
+ return staleSafe(callback);
102
+ }
103
+ function currentSessionId(ctx) {
104
+ return safeCtxCall(() => ctx.sessionManager.getSessionId());
105
+ }
79
106
  function currentSessionName(ctx) {
80
- const name = pi.getSessionName() ?? ctx?.sessionManager.getSessionName?.();
107
+ const name = safePiCall(() => pi.getSessionName()) ?? safeCtxCall(() => ctx?.sessionManager.getSessionName?.());
81
108
  return name?.trim() || undefined;
82
109
  }
83
110
  function shouldGeneratePendingTitle(ctx) {
84
111
  if (!pendingGeneration)
85
112
  return false;
86
- if (pendingGeneration.sessionId !== ctx.sessionManager.getSessionId())
113
+ if (pendingGeneration.sessionId !== currentSessionId(ctx))
87
114
  return false;
88
115
  const name = currentSessionName(ctx);
89
116
  if (!name)
@@ -110,7 +137,12 @@ export default function sessionTitle(pi) {
110
137
  const safeTitle = terminalSafeText(title) || DEFAULT_TERMINAL_TITLE;
111
138
  if (!force && safeTitle === lastRenderedTitle)
112
139
  return;
113
- ctx.ui.setTitle(safeTitle);
140
+ const rendered = safeCtxCall(() => {
141
+ ctx.ui.setTitle(safeTitle);
142
+ return true;
143
+ });
144
+ if (!rendered)
145
+ return;
114
146
  lastRenderedTitle = safeTitle;
115
147
  }
116
148
  function refreshSessionUi(ctx, options = {}) {
@@ -130,7 +162,7 @@ export default function sessionTitle(pi) {
130
162
  for (const delayMs of [0, 100, 500, 1500, 3000]) {
131
163
  const timer = setTimeout(() => {
132
164
  refreshTimers.delete(timer);
133
- refreshSessionUi(ctx, { reapplyTitle: true });
165
+ safeCtxCall(() => refreshSessionUi(ctx, { reapplyTitle: true }));
134
166
  }, delayMs);
135
167
  timer.unref?.();
136
168
  refreshTimers.add(timer);
@@ -146,7 +178,7 @@ export default function sessionTitle(pi) {
146
178
  return;
147
179
  retryTimer = setTimeout(() => {
148
180
  retryTimer = undefined;
149
- startTitleGeneration(ctx, currentConfig);
181
+ safeCtxCall(() => startTitleGeneration(ctx, currentConfig));
150
182
  }, currentConfig.retryDelayMs);
151
183
  retryTimer.unref?.();
152
184
  }
@@ -157,7 +189,11 @@ export default function sessionTitle(pi) {
157
189
  const fallbackTitle = fallbackSessionTitleFromInput(input, currentConfig.maxTitleChars);
158
190
  if (!fallbackTitle)
159
191
  return false;
160
- pi.setSessionName(fallbackTitle);
192
+ if (!safePiCall(() => {
193
+ pi.setSessionName(fallbackTitle);
194
+ return true;
195
+ }))
196
+ return false;
161
197
  refreshSessionUi(ctx, { force: true });
162
198
  scheduleSessionUiRefresh(ctx);
163
199
  return true;
@@ -185,36 +221,47 @@ export default function sessionTitle(pi) {
185
221
  abortCurrentRequest();
186
222
  controller = new AbortController();
187
223
  const requestController = controller;
188
- const currentSessionId = pendingGeneration.sessionId;
224
+ const pendingSessionId = pendingGeneration.sessionId;
189
225
  const generation = { ...pendingGeneration, modelRef };
190
226
  void (async () => {
191
227
  try {
192
- const title = await generateSessionTitle(generation.input, ctx.modelRegistry, currentConfig, generation.modelRef, requestController.signal, currentConfig.debug && ctx.hasUI ? (message) => ctx.ui.notify(message, "warning") : undefined);
228
+ const notifyDebug = currentConfig.debug && ctx.hasUI
229
+ ? (message) => {
230
+ safeCtxCall(() => ctx.ui.notify(message, "warning"));
231
+ }
232
+ : undefined;
233
+ const title = await generateSessionTitle(generation.input, ctx.modelRegistry, currentConfig, generation.modelRef, requestController.signal, notifyDebug);
193
234
  if (!title || requestController.signal.aborted)
194
235
  return;
195
- if (sessionId !== currentSessionId)
236
+ if (sessionId !== pendingSessionId)
237
+ return;
238
+ if (pendingSessionId !== currentSessionId(ctx))
196
239
  return;
197
240
  if (!shouldGeneratePendingTitle(ctx))
198
241
  return;
199
- pi.setSessionName(title);
242
+ if (!safePiCall(() => {
243
+ pi.setSessionName(title);
244
+ return true;
245
+ }))
246
+ return;
200
247
  pendingGeneration = undefined;
201
248
  refreshSessionUi(ctx, { force: true });
202
249
  scheduleSessionUiRefresh(ctx);
203
250
  if (currentConfig.notify && ctx.hasUI)
204
- ctx.ui.notify(`Session named: ${title}`, "info");
251
+ safeCtxCall(() => ctx.ui.notify(`Session named: ${title}`, "info"));
205
252
  }
206
253
  catch (error) {
207
254
  if (requestController.signal.aborted)
208
255
  return;
209
256
  if (currentConfig.debug && ctx.hasUI) {
210
257
  const message = error instanceof Error ? error.message : String(error);
211
- ctx.ui.notify(`Session title generation failed: ${message}`, "warning");
258
+ safeCtxCall(() => ctx.ui.notify(`Session title generation failed: ${message}`, "warning"));
212
259
  }
213
260
  }
214
261
  finally {
215
262
  if (controller === requestController)
216
263
  controller = undefined;
217
- if (requestController.signal.aborted || pendingGeneration?.sessionId !== currentSessionId)
264
+ if (requestController.signal.aborted || pendingGeneration?.sessionId !== pendingSessionId)
218
265
  return;
219
266
  if (shouldGeneratePendingTitle(ctx)) {
220
267
  if (!advancePendingGeneration(currentConfig)) {
@@ -363,7 +410,11 @@ export default function sessionTitle(pi) {
363
410
  : fallbackInput;
364
411
  const provisionalSessionName = fallbackSessionTitleFromInput(fallbackTitleInput, currentConfig.maxTitleChars);
365
412
  if (provisionalSessionName && (!currentName || activeForkTitleState)) {
366
- pi.setSessionName(provisionalSessionName);
413
+ if (!safePiCall(() => {
414
+ pi.setSessionName(provisionalSessionName);
415
+ return true;
416
+ }))
417
+ return { action: "continue" };
367
418
  refreshSessionUi(ctx, { force: true });
368
419
  scheduleSessionUiRefresh(ctx);
369
420
  }
@@ -1,5 +1,24 @@
1
+ import { createRequire } from "node:module";
2
+ import { dirname, join } from "node:path";
3
+ import { pathToFileURL } from "node:url";
1
4
  import { resizeImage } from "@earendil-works/pi-coding-agent";
2
- import { hasImage, getImageBinary } from "@mariozechner/clipboard";
5
+ const moduleRequire = createRequire(import.meta.url);
6
+ const executableDirRequire = createRequire(pathToFileURL(join(dirname(process.execPath), "package.json")).href);
7
+ function loadClipboardNative(requires = [moduleRequire, executableDirRequire]) {
8
+ for (const requireClipboard of requires) {
9
+ try {
10
+ return requireClipboard("@mariozechner/clipboard");
11
+ }
12
+ catch {
13
+ // Try the next resolution root. This mirrors pi's packaged-binary fallback,
14
+ // where native sidecars may resolve relative to the executable directory.
15
+ }
16
+ }
17
+ return null;
18
+ }
19
+ const nativeClipboard = !process.env.TERMUX_VERSION && (process.platform !== "linux" || Boolean(process.env.DISPLAY || process.env.WAYLAND_DISPLAY))
20
+ ? loadClipboardNative()
21
+ : null;
3
22
  /**
4
23
  * Read an image from the system clipboard.
5
24
  * Uses the native @mariozechner/clipboard N-API module for direct clipboard
@@ -7,12 +26,12 @@ import { hasImage, getImageBinary } from "@mariozechner/clipboard";
7
26
  */
8
27
  export async function readClipboardImage() {
9
28
  try {
10
- if (!hasImage())
29
+ if (!nativeClipboard?.hasImage())
11
30
  return null;
12
- const bytes = await getImageBinary();
31
+ const bytes = await nativeClipboard.getImageBinary();
13
32
  if (!bytes || bytes.length === 0)
14
33
  return null;
15
- const uint8 = new Uint8Array(bytes);
34
+ const uint8 = bytes instanceof Uint8Array ? bytes : Uint8Array.from(bytes);
16
35
  try {
17
36
  const resized = await resizeImage(uint8, "image/png", { maxWidth: 2000, maxHeight: 2000 });
18
37
  if (resized)
@@ -22,8 +22,11 @@ export type RenderedMarkdownTextLine = {
22
22
  syntaxHighlight?: SyntaxLineHighlight | undefined;
23
23
  heading?: boolean;
24
24
  };
25
+ export type RenderMarkdownTextLinesOptions = {
26
+ preserveWrappedWordSeparator?: boolean;
27
+ };
25
28
  export declare function formatMarkdownTables(text: string, maxWidth?: number): string;
26
29
  export declare function renderMarkdownLine(text: string, start?: number): RenderedMarkdownLine;
27
- export declare function renderMarkdownTextLines(text: string, width: number, start?: number): RenderedMarkdownTextLine[];
30
+ export declare function renderMarkdownTextLines(text: string, width: number, start?: number, options?: RenderMarkdownTextLinesOptions): RenderedMarkdownTextLine[];
28
31
  export declare function markdownSyntaxHighlightsForText(text: string, startColumn?: number): ToolBodySyntaxHighlights;
29
32
  export declare function isOnlyHiddenMetadata(text: string): boolean;
@@ -1,5 +1,6 @@
1
1
  import { expandTabs, stringDisplayWidth } from "./terminal-width.js";
2
2
  import { syntaxHighlightLanguageForMarkdownFence, } from "./syntax-highlight.js";
3
+ const MIN_TRAILING_WORD_WIDTH_TO_REBALANCE = 5;
3
4
  export function formatMarkdownTables(text, maxWidth) {
4
5
  const lines = text.split("\n");
5
6
  const formatted = [];
@@ -70,7 +71,7 @@ export function renderMarkdownLine(text, start = 0) {
70
71
  }
71
72
  return { text: rendered, segments, ...(isHeading ? { heading: true } : {}) };
72
73
  }
73
- export function renderMarkdownTextLines(text, width, start = 0) {
74
+ export function renderMarkdownTextLines(text, width, start = 0, options = {}) {
74
75
  const lines = [];
75
76
  let fence;
76
77
  const formattedText = formatMarkdownTables(sanitizeMarkdownText(text), width);
@@ -83,7 +84,7 @@ export function renderMarkdownTextLines(text, width, start = 0) {
83
84
  const syntaxHighlight = markdownLineSyntaxHighlight(fence, Boolean(opensFence || closesFence), start);
84
85
  const isHeadingLine = !fence && /^\s{0,3}#{1,6}\s/.test(rawLine);
85
86
  const markdownLine = syntaxHighlight?.language === "markdown" || isHeadingLine ? renderMarkdownLine(rawLine) : undefined;
86
- for (const wrapped of wrapRenderedMarkdownLine(markdownLine ?? { text: rawLine, segments: [] }, width)) {
87
+ for (const wrapped of wrapRenderedMarkdownLine(markdownLine ?? { text: rawLine, segments: [] }, width, options)) {
87
88
  lines.push({
88
89
  text: wrapped.text,
89
90
  ...(wrapped.copyText === undefined ? {} : { copyText: wrapped.copyText }),
@@ -122,11 +123,11 @@ export function markdownSyntaxHighlightsForText(text, startColumn = 0) {
122
123
  }
123
124
  return highlights;
124
125
  }
125
- function wrapRenderedMarkdownLine(line, width) {
126
+ function wrapRenderedMarkdownLine(line, width, options) {
126
127
  const safeWidth = Math.max(1, width);
127
128
  if (stringDisplayWidth(line.text) <= safeWidth)
128
129
  return [line];
129
- const ranges = wrapDisplayLineByWordsWithRanges(line.text, safeWidth);
130
+ const ranges = wrapDisplayLineByWordsWithRanges(line.text, safeWidth, options);
130
131
  return ranges.map((range, index) => ({
131
132
  text: range.text,
132
133
  copyText: line.text.slice(range.start, ranges[index + 1]?.start ?? range.end),
@@ -134,7 +135,7 @@ function wrapRenderedMarkdownLine(line, width) {
134
135
  segments: line.segments.flatMap((segment) => shiftSegmentToRange(segment, range.start, range.end)),
135
136
  }));
136
137
  }
137
- function wrapDisplayLineByWordsWithRanges(text, width) {
138
+ function wrapDisplayLineByWordsWithRanges(text, width, options) {
138
139
  const chunks = [];
139
140
  let chunkText = "";
140
141
  let chunkStart = 0;
@@ -165,10 +166,19 @@ function wrapDisplayLineByWordsWithRanges(text, width) {
165
166
  chunkEnd = token.end;
166
167
  }
167
168
  else {
168
- chunks.push(trimTrailingWhitespaceChunk(chunkText, chunkStart));
169
- chunkText = "";
170
- chunkStart = token.end;
171
- chunkEnd = token.end;
169
+ const rewrapped = options.preserveWrappedWordSeparator
170
+ ? splitChunkBeforeTrailingWord(chunkText, chunkStart, token, width)
171
+ : undefined;
172
+ if (rewrapped) {
173
+ chunks.push(rewrapped.previous);
174
+ setChunk(rewrapped.next);
175
+ }
176
+ else {
177
+ chunks.push(trimTrailingWhitespaceChunk(chunkText, chunkStart));
178
+ chunkText = "";
179
+ chunkStart = token.end;
180
+ chunkEnd = chunkStart;
181
+ }
172
182
  }
173
183
  continue;
174
184
  }
@@ -182,6 +192,14 @@ function wrapDisplayLineByWordsWithRanges(text, width) {
182
192
  chunkEnd = token.end;
183
193
  continue;
184
194
  }
195
+ const rewrapped = options.preserveWrappedWordSeparator
196
+ ? splitChunkBeforeTrailingWordWithNextToken(chunkText, chunkStart, token, width)
197
+ : undefined;
198
+ if (rewrapped) {
199
+ chunks.push(rewrapped.previous);
200
+ setChunk(rewrapped.next);
201
+ continue;
202
+ }
185
203
  chunks.push(trimTrailingWhitespaceChunk(chunkText, chunkStart));
186
204
  appendTokenToEmptyChunk(token);
187
205
  }
@@ -238,6 +256,55 @@ function trimTrailingWhitespaceChunk(text, start) {
238
256
  const trimmed = text.replace(/\s+$/u, "");
239
257
  return { text: trimmed, start, end: start + trimmed.length };
240
258
  }
259
+ function splitChunkBeforeTrailingWord(chunkText, chunkStart, separator, width) {
260
+ const match = /^(.*\S)(\s+)(\S+)$/u.exec(chunkText);
261
+ if (!match)
262
+ return undefined;
263
+ const prefix = match[1] ?? "";
264
+ const whitespace = match[2] ?? "";
265
+ const trailingWord = match[3] ?? "";
266
+ if (!prefix || !whitespace || !trailingWord)
267
+ return undefined;
268
+ const nextText = `${trailingWord}${separator.text}`;
269
+ if (stringDisplayWidth(nextText) > width)
270
+ return undefined;
271
+ const previous = trimTrailingWhitespaceChunk(prefix, chunkStart);
272
+ if (!previous.text)
273
+ return undefined;
274
+ const trailingWordStart = chunkStart + prefix.length + whitespace.length;
275
+ return {
276
+ previous,
277
+ next: { text: nextText, start: trailingWordStart, end: separator.end },
278
+ };
279
+ }
280
+ function splitChunkBeforeTrailingWordWithNextToken(chunkText, chunkStart, nextToken, width) {
281
+ const match = /^(.*\S)(\s+)(\S+)(\s+)$/u.exec(chunkText);
282
+ if (!match)
283
+ return undefined;
284
+ const prefix = match[1] ?? "";
285
+ const whitespace = match[2] ?? "";
286
+ const trailingWord = match[3] ?? "";
287
+ const trailingWhitespace = match[4] ?? "";
288
+ if (!prefix || !whitespace || !trailingWord || !trailingWhitespace)
289
+ return undefined;
290
+ if (!shouldMoveTrailingWordToPreserveSeparator(prefix, trailingWord, width))
291
+ return undefined;
292
+ const nextText = `${trailingWord}${trailingWhitespace}${nextToken.text}`;
293
+ if (stringDisplayWidth(nextText) > width)
294
+ return undefined;
295
+ const previous = trimTrailingWhitespaceChunk(prefix, chunkStart);
296
+ if (!previous.text)
297
+ return undefined;
298
+ const trailingWordStart = chunkStart + prefix.length + whitespace.length;
299
+ return {
300
+ previous,
301
+ next: { text: nextText, start: trailingWordStart, end: nextToken.end },
302
+ };
303
+ }
304
+ function shouldMoveTrailingWordToPreserveSeparator(prefix, trailingWord, width) {
305
+ return stringDisplayWidth(prefix) >= Math.floor(width / 2)
306
+ && stringDisplayWidth(trailingWord) >= MIN_TRAILING_WORD_WIDTH_TO_REBALANCE;
307
+ }
241
308
  function shiftSegmentToRange(segment, rangeStart, rangeEnd) {
242
309
  const start = Math.max(segment.start, rangeStart);
243
310
  const end = Math.min(segment.end, rangeEnd);