@vanillagreen/pi-qol 1.0.1 → 1.0.3

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 (2) hide show
  1. package/extensions/qol.ts +131 -16
  2. package/package.json +1 -1
package/extensions/qol.ts CHANGED
@@ -9,6 +9,7 @@ import { homedir } from "node:os";
9
9
  import { basename, dirname, isAbsolute, join, resolve } from "node:path";
10
10
 
11
11
  const INSTALL_SYMBOL = Symbol.for("vstack.pi-qol.installed");
12
+ const CONFIG_ID = "@vanillagreen/pi-qol";
12
13
  const STATUS_KEY = "qol-attachments";
13
14
  const SESSION_SEARCH_STATUS_KEY = "qol-session-search";
14
15
  const SESSION_SEARCH_CONTEXT_TYPE = "qol-session-context";
@@ -19,6 +20,27 @@ const IMAGE_PATH_PATTERN = /(^|[\s(\[{<"'`])(@?(?:~|\.\.?|\/)[^\s)\]}>"'`]+?\.(?
19
20
  const QUESTION_SERVICE_SYMBOL = Symbol.for("vstack.pi-questions.service");
20
21
  const QOL_NOTIFICATION_SERVICE_SYMBOL = Symbol.for("vstack.pi-qol.notification-service");
21
22
  const VSTACK_MODAL_LOCK_SYMBOL = Symbol.for("vstack.pi.modal-lock");
23
+ const CAVEMAN_BRIDGE_SYMBOL = Symbol.for("vstack.pi.caveman");
24
+ const CAVEMAN_ICON_ACTIVE = "\uee9a";
25
+ const CAVEMAN_ICON_INACTIVE = "\u{f19e0}";
26
+
27
+ interface CavemanBridge {
28
+ isActive(): boolean;
29
+ getMode(): string;
30
+ getConfiguredMode?(cwd?: string): string;
31
+ getLastActiveMode(): string;
32
+ hasSessionOverride?(): boolean;
33
+ isStatusBadgeEnabled?(cwd?: string): boolean;
34
+ cycleMode?(cwd?: string): string;
35
+ setMode?(mode: string, cwd?: string): string | undefined;
36
+ subscribe(listener: () => void): () => void;
37
+ }
38
+
39
+ function readCavemanBridge(): CavemanBridge | undefined {
40
+ const host = globalThis as unknown as Record<PropertyKey, unknown>;
41
+ const value = host[CAVEMAN_BRIDGE_SYMBOL];
42
+ return value && typeof value === "object" ? (value as CavemanBridge) : undefined;
43
+ }
22
44
  const THINKING_TIMER_STORE_SYMBOL = Symbol.for("vstack.pi-qol.thinking-timer.store");
23
45
  const THINKING_TIMER_PATCH_SYMBOL = Symbol.for("vstack.pi-qol.thinking-timer.patch");
24
46
  const SESSION_SEARCH_PENDING_SYMBOL = Symbol.for("vstack.pi-qol.session-search.pending-context");
@@ -44,6 +66,7 @@ const DEFAULT_SESSION_SEARCH_SHORTCUT = "f3";
44
66
  const DEFAULT_SESSION_SEARCH_SUMMARY_INPUT_CHARS = 180_000;
45
67
  const DEFAULT_SESSION_SEARCH_SUMMARY_MAX_TOKENS = 4096;
46
68
  const DEFAULT_SESSION_SEARCH_CACHE_TTL_SECONDS = 0;
69
+ const SESSION_SEARCH_OVERLAY_HEIGHT_RATIO = 0.9;
47
70
  const DEFAULT_AUTO_RENAME_MODEL = "openai-codex/gpt-5.4-mini";
48
71
  const DEFAULT_AUTO_RENAME_FALLBACK_MODEL = "current";
49
72
  const DEFAULT_AUTO_RENAME_INPUT_CHARS = 2000;
@@ -131,7 +154,7 @@ function readVstackConfig(cwd?: string): VstackConfig {
131
154
  if (!existsSync(path)) continue;
132
155
  try {
133
156
  const parsed = JSON.parse(readFileSync(path, "utf8"));
134
- const config = parsed?.vstack?.extensionManager?.config?.["pi-qol"];
157
+ const config = parsed?.vstack?.extensionManager?.config?.[CONFIG_ID];
135
158
  if (config && typeof config === "object" && !Array.isArray(config)) Object.assign(merged, config);
136
159
  } catch {
137
160
  // Ignore malformed optional manager config.
@@ -938,6 +961,16 @@ function statusText(ctx: ExtensionContext, text: string): string | undefined {
938
961
  }
939
962
 
940
963
  function handleQolEditorInput(editor: CustomEditor, ctx: ExtensionContext, data: string): void {
964
+ if (matchesKey(data, "alt+c")) {
965
+ const caveman = readCavemanBridge();
966
+ if (caveman?.cycleMode) {
967
+ const next = caveman.cycleMode(ctx.cwd);
968
+ if (typeof next === "string") {
969
+ try { ctx.ui.notify(next === "off" ? "Caveman off." : `Caveman ${next} active.`, "info"); } catch { /* notify is best-effort */ }
970
+ }
971
+ return;
972
+ }
973
+ }
941
974
  const fallback = newlineFallbackKey(ctx.cwd);
942
975
  const newlineEnabled = settingBoolean("newlineOnShiftEnter", true, ctx.cwd);
943
976
  const isShiftEnter = matchesKey(data, "shift+enter") || matchesKey(data, "shift+return");
@@ -1066,9 +1099,9 @@ function repoNameFromRemote(remote: string): string | undefined {
1066
1099
  return match?.[1];
1067
1100
  }
1068
1101
 
1069
- function formatModel(ctx: ExtensionContext, pi: ExtensionAPI): string {
1102
+ function formatModelName(ctx: ExtensionContext): string {
1070
1103
  const model = ctx.model;
1071
- if (!model) return `no model / ${pi.getThinkingLevel()}`;
1104
+ if (!model) return "no model";
1072
1105
  let name = model.name || model.id;
1073
1106
  name = name.replace(/^Claude\s+/i, "");
1074
1107
  name = name.replace(/^claude[-_]/i, "");
@@ -1080,7 +1113,29 @@ function formatModel(ctx: ExtensionContext, pi: ExtensionAPI): string {
1080
1113
  name = name.replace(/\bhaiku\b/i, "Haiku");
1081
1114
  name = name.replace(/\s+/g, " ").trim();
1082
1115
  name = name.replace(/\b(Opus|Sonnet|Haiku) (\d) (\d)\b/, "$1 $2.$3");
1083
- return `${name} / ${pi.getThinkingLevel()}`;
1116
+ return name;
1117
+ }
1118
+
1119
+ type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
1120
+ const THINKING_TOKEN: Record<ThinkingLevel, "thinkingOff" | "thinkingMinimal" | "thinkingLow" | "thinkingMedium" | "thinkingHigh" | "thinkingXhigh"> = {
1121
+ off: "thinkingOff",
1122
+ minimal: "thinkingMinimal",
1123
+ low: "thinkingLow",
1124
+ medium: "thinkingMedium",
1125
+ high: "thinkingHigh",
1126
+ xhigh: "thinkingXhigh",
1127
+ };
1128
+
1129
+ function normalizeThinkingLevel(value: string | undefined): ThinkingLevel {
1130
+ switch ((value ?? "").toLowerCase()) {
1131
+ case "off": return "off";
1132
+ case "minimal": return "minimal";
1133
+ case "low": return "low";
1134
+ case "medium": return "medium";
1135
+ case "high": return "high";
1136
+ case "xhigh": return "xhigh";
1137
+ default: return "off";
1138
+ }
1084
1139
  }
1085
1140
 
1086
1141
  function formatWindow(tokens: number | undefined): string {
@@ -1189,19 +1244,44 @@ function setTmuxWindowOption(target: string, option: string, value: string): voi
1189
1244
  execFile("tmux", ["set-option", "-wq", "-t", target, option, value], { timeout: 1000 }, () => undefined);
1190
1245
  }
1191
1246
 
1247
+ function cavemanIconTone(mode: string, active: boolean): "muted" | "text" | "success" | "thinkingHigh" | "error" {
1248
+ if (!active) return "muted";
1249
+ switch (mode) {
1250
+ case "micro": return "text";
1251
+ case "lite": return "success";
1252
+ case "full": return "thinkingHigh";
1253
+ case "ultra": return "error";
1254
+ default: return "muted";
1255
+ }
1256
+ }
1257
+
1192
1258
  function renderStatusLine(width: number, ctx: ExtensionContext, git: GitState, pi: ExtensionAPI, theme: Pick<Theme, "fg">): string {
1193
1259
  const { label: contextLabel, percent } = statuslineContextInfo(ctx);
1194
- const leftPlain = `${git.projectName}${gitBadge(git, settingBoolean("showDirtyMarker", true, ctx.cwd))} ${formatModel(ctx, pi)} (${contextLabel})`;
1260
+ const projectChunk = `${git.projectName}${gitBadge(git, settingBoolean("showDirtyMarker", true, ctx.cwd))} ${formatModelName(ctx)} / `;
1261
+ const thinkingLevel = normalizeThinkingLevel(pi.getThinkingLevel());
1262
+ const thinkingChunk = thinkingLevel;
1263
+ const contextChunk = ` (${contextLabel})`;
1264
+ const cavemanBridge = readCavemanBridge();
1265
+ const cavemanVisible = !!cavemanBridge && (cavemanBridge.isStatusBadgeEnabled?.(ctx.cwd) ?? true);
1266
+ const caveman = cavemanVisible ? cavemanBridge : undefined;
1267
+ const cavemanActive = caveman?.isActive() ?? false;
1268
+ const cavemanGlyph = caveman ? (cavemanActive ? CAVEMAN_ICON_ACTIVE : CAVEMAN_ICON_INACTIVE) : "";
1269
+ const cavemanTone = cavemanIconTone(caveman?.getMode() ?? "off", cavemanActive);
1270
+ const cavemanSegment = caveman ? ` / ${cavemanGlyph}` : "";
1271
+ const contextSeparator = caveman ? " / " : "";
1272
+ const leftPlain = `${projectChunk}${thinkingChunk}${cavemanSegment}${contextSeparator}${contextChunk.trimStart()}`;
1195
1273
  const rightPlain = percent === null ? "…%" : `${percent}%`;
1196
1274
  const percentColor = percent === null ? "muted" : percent <= 15 ? "error" : percent <= 30 ? "warning" : "success";
1197
- const left = theme.fg("accent", leftPlain);
1275
+ const leftColored = caveman
1276
+ ? `${theme.fg("accent", projectChunk)}${theme.fg(THINKING_TOKEN[thinkingLevel], thinkingChunk)}${theme.fg("accent", " / ")}${theme.fg(cavemanTone, cavemanGlyph)}${theme.fg("accent", `${contextSeparator}${contextChunk.trimStart()}`)}`
1277
+ : `${theme.fg("accent", projectChunk)}${theme.fg(THINKING_TOKEN[thinkingLevel], thinkingChunk)}${theme.fg("accent", contextChunk)}`;
1198
1278
  const right = theme.fg(percentColor, rightPlain);
1199
1279
  const minimumGap = 1;
1200
1280
  const gapWidth = Math.max(minimumGap, width - visibleWidth(leftPlain) - visibleWidth(rightPlain) - 2);
1201
1281
  const filled = percent === null ? 0 : Math.round(gapWidth * (percent / 100));
1202
1282
  const empty = Math.max(0, gapWidth - filled);
1203
1283
  const bar = " ".repeat(empty) + theme.fg("warning", "─".repeat(filled));
1204
- return truncateToWidth(`${left} ${bar} ${right}`, width, "");
1284
+ return truncateToWidth(`${leftColored} ${bar} ${right}`, width, "");
1205
1285
  }
1206
1286
 
1207
1287
  type QolSummaryProfile = "concise" | "balanced" | "exhaustive";
@@ -3071,7 +3151,7 @@ class QolSessionSearchComponent {
3071
3151
 
3072
3152
  constructor(
3073
3153
  private readonly done: (action: QolSessionPaletteAction) => void,
3074
- private readonly tui: { requestRender(): void },
3154
+ private readonly tui: { requestRender(): void; terminal?: { rows?: number } },
3075
3155
  private readonly theme: Theme,
3076
3156
  private readonly sessions: QolSessionSearchSession[],
3077
3157
  private readonly cwd: string,
@@ -3089,6 +3169,30 @@ class QolSessionSearchComponent {
3089
3169
 
3090
3170
  invalidate(): void {}
3091
3171
 
3172
+ private maxOverlayRows(): number {
3173
+ const terminalRows = Number(this.tui.terminal?.rows ?? process.stdout.rows ?? 30);
3174
+ const safeRows = Number.isFinite(terminalRows) && terminalRows > 0 ? terminalRows : 30;
3175
+ return Math.max(8, Math.floor(safeRows * SESSION_SEARCH_OVERLAY_HEIGHT_RATIO));
3176
+ }
3177
+
3178
+ private searchMaxVisibleRows(): number {
3179
+ const configured = Math.max(1, Math.floor(settingNumber("sessionSearch.maxVisible", 8, this.cwd)));
3180
+ // Search results render as 3 content rows plus a divider between rows.
3181
+ // The remaining chrome is title, search box, help text, scroll status,
3182
+ // footer, and frame. Keep the rendered line count within overlay maxHeight
3183
+ // so Pi does not clip the bottom of the popup on short terminals.
3184
+ const responsive = Math.max(1, Math.floor((this.maxOverlayRows() - 12) / 4));
3185
+ return Math.max(1, Math.min(configured, responsive));
3186
+ }
3187
+
3188
+ private messageMaxVisibleRows(): number {
3189
+ const configured = Math.max(1, Math.floor(settingNumber("sessionSearch.messageMaxVisible", 12, this.cwd)));
3190
+ // Message rows are one line each; reserve space for session metadata,
3191
+ // optional scroll status, footer, and frame.
3192
+ const responsive = Math.max(1, this.maxOverlayRows() - 14);
3193
+ return Math.max(1, Math.min(configured, responsive));
3194
+ }
3195
+
3092
3196
  render(width: number): string[] {
3093
3197
  const configured = Math.max(70, Math.floor(settingNumber("sessionSearch.overlayWidth", 104, this.cwd)));
3094
3198
  const renderWidth = Math.min(Math.max(48, width), configured);
@@ -3165,11 +3269,11 @@ class QolSessionSearchComponent {
3165
3269
  return;
3166
3270
  }
3167
3271
  if (matchesKey(data, "-") || matchesKey(data, "pageup")) {
3168
- state.selected = Math.max(0, state.selected - 10);
3272
+ state.selected = Math.max(0, state.selected - this.searchMaxVisibleRows());
3169
3273
  return;
3170
3274
  }
3171
3275
  if (matchesKey(data, "=") || matchesKey(data, "pagedown")) {
3172
- state.selected = Math.min(state.results.length - 1, state.selected + 10);
3276
+ state.selected = Math.min(state.results.length - 1, state.selected + this.searchMaxVisibleRows());
3173
3277
  return;
3174
3278
  }
3175
3279
  if (matchesKey(data, "left")) {
@@ -3239,11 +3343,11 @@ class QolSessionSearchComponent {
3239
3343
  return;
3240
3344
  }
3241
3345
  if (matchesKey(data, "-") || matchesKey(data, "pageup")) {
3242
- state.selected = Math.max(0, state.selected - 10);
3346
+ state.selected = Math.max(0, state.selected - this.messageMaxVisibleRows());
3243
3347
  return;
3244
3348
  }
3245
3349
  if (matchesKey(data, "=") || matchesKey(data, "pagedown")) {
3246
- state.selected = Math.min(state.messages.length - 1, state.selected + 10);
3350
+ state.selected = Math.min(state.messages.length - 1, state.selected + this.messageMaxVisibleRows());
3247
3351
  return;
3248
3352
  }
3249
3353
  if (matchesKey(data, "home") || matchesKey(data, "ctrl+a")) {
@@ -3365,7 +3469,7 @@ class QolSessionSearchComponent {
3365
3469
  if (state.results.length === 0) {
3366
3470
  lines.push(row(muted(state.query.trim() ? "No sessions match your search" : "No sessions found")), empty());
3367
3471
  } else {
3368
- const maxVisible = Math.max(3, Math.floor(settingNumber("sessionSearch.maxVisible", 8, this.cwd)));
3472
+ const maxVisible = this.searchMaxVisibleRows();
3369
3473
  const start = Math.max(0, Math.min(state.selected - Math.floor(maxVisible / 2), state.results.length - maxVisible));
3370
3474
  const end = Math.min(start + maxVisible, state.results.length);
3371
3475
  const rowBudget = Math.max(10, inner);
@@ -3394,7 +3498,7 @@ class QolSessionSearchComponent {
3394
3498
  if (state.results.length > maxVisible) lines.push(empty(), row(dim(`${state.selected + 1}/${state.results.length} ${state.query.trim() ? "matches" : "recent sessions"}`)));
3395
3499
  }
3396
3500
  lines.push(divider(), empty());
3397
- lines.push(row(`${ansiYellow("↑↓")} ${dim("sessions")} ${ansiYellow("-/=")} ${dim("page")} ${ansiYellow("enter")} ${dim("prompts")} ${ansiYellow("ctrl+u")} ${dim("clear")} ${ansiYellow("esc")} ${dim("close")}`));
3501
+ lines.push(row(`${ansiYellow("-/=")} ${dim("page")} ${ansiYellow("enter")} ${dim("prompts")} ${ansiYellow("ctrl+u")} ${dim("clear")} ${ansiYellow("esc")} ${dim("close")}`));
3398
3502
  lines.push(bottom());
3399
3503
  return lines;
3400
3504
  }
@@ -3419,7 +3523,7 @@ class QolSessionSearchComponent {
3419
3523
  lines.push(row(dim(`${state.messages.length} user prompt${state.messages.length === 1 ? "" : "s"} · enter opens prompt actions · r resumes latest session state`)));
3420
3524
  lines.push(empty(), divider(), empty());
3421
3525
 
3422
- const maxVisible = Math.max(6, Math.floor(settingNumber("sessionSearch.messageMaxVisible", 12, this.cwd)));
3526
+ const maxVisible = this.messageMaxVisibleRows();
3423
3527
  const start = Math.max(0, Math.min(state.selected - Math.floor(maxVisible / 2), state.messages.length - maxVisible));
3424
3528
  const end = Math.min(start + maxVisible, state.messages.length);
3425
3529
  for (let i = start; i < end; i++) {
@@ -3434,7 +3538,7 @@ class QolSessionSearchComponent {
3434
3538
  }
3435
3539
  if (state.messages.length > maxVisible) lines.push(empty(), row(dim(`${state.selected + 1}/${state.messages.length} user prompts`)));
3436
3540
  lines.push(divider(), empty());
3437
- lines.push(row(`${ansiYellow("↑↓")} ${dim("prompts")} ${ansiYellow("-/=")} ${dim("page")} ${ansiYellow("enter")} ${dim("prompt actions")} ${ansiYellow("r")} ${dim("resume session")} ${ansiYellow("esc")} ${dim("sessions")}`));
3541
+ lines.push(row(`${ansiYellow("-/=")} ${dim("page")} ${ansiYellow("enter")} ${dim("prompt actions")} ${ansiYellow("r")} ${dim("resume session")} ${ansiYellow("esc")} ${dim("sessions")}`));
3438
3542
  lines.push(bottom());
3439
3543
  return lines;
3440
3544
  }
@@ -4122,6 +4226,14 @@ export default function qol(pi: ExtensionAPI): void {
4122
4226
  };
4123
4227
 
4124
4228
  let currentCtx: ExtensionContext | undefined;
4229
+ let cavemanUnsubscribe: (() => void) | undefined;
4230
+ const subscribeCavemanBridge = () => {
4231
+ cavemanUnsubscribe?.();
4232
+ cavemanUnsubscribe = undefined;
4233
+ const bridge = readCavemanBridge();
4234
+ if (!bridge) return;
4235
+ cavemanUnsubscribe = bridge.subscribe(() => requestRender());
4236
+ };
4125
4237
  const notificationService: QolNotificationService = {
4126
4238
  notifyQuestionOpened(ctx, event) {
4127
4239
  notifyQuestionOpened(ctx ?? currentCtx, event, "question");
@@ -4138,6 +4250,7 @@ export default function qol(pi: ExtensionAPI): void {
4138
4250
 
4139
4251
  pi.on("session_start", (event, ctx) => {
4140
4252
  currentCtx = ctx;
4253
+ subscribeCavemanBridge();
4141
4254
  latestSystemPromptOptions = undefined;
4142
4255
  resetAutoRename();
4143
4256
  resetThinkingTimer(ctx);
@@ -4196,6 +4309,8 @@ export default function qol(pi: ExtensionAPI): void {
4196
4309
  });
4197
4310
 
4198
4311
  pi.on("session_shutdown", (_event, ctx) => {
4312
+ cavemanUnsubscribe?.();
4313
+ cavemanUnsubscribe = undefined;
4199
4314
  resetAutoRename();
4200
4315
  clearIdleCompactionTimer();
4201
4316
  clearQuestionSubscribeTimer();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vanillagreen/pi-qol",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
4
4
  "description": "Pi quality-of-life extension: compact statusline/π prompt, reliable multiline input, styled pasted-image chips, session naming/search/context import, handoff, permission prompts, notifications, custom compaction, and a collapsed-thinking timer.",
5
5
  "license": "MIT",
6
6
  "keywords": [