@oh-my-pi/pi-coding-agent 15.11.3 → 15.11.4

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 (103) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/dist/cli.js +353 -294
  3. package/dist/types/config/api-key-resolver.d.ts +9 -3
  4. package/dist/types/config/keybindings.d.ts +1 -1
  5. package/dist/types/config/model-discovery.d.ts +6 -4
  6. package/dist/types/config/model-registry.d.ts +7 -4
  7. package/dist/types/config/settings-schema.d.ts +458 -155
  8. package/dist/types/export/html/template.generated.d.ts +1 -1
  9. package/dist/types/mnemopi/config.d.ts +3 -1
  10. package/dist/types/modes/components/settings-defs.d.ts +9 -2
  11. package/dist/types/modes/components/settings-selector.d.ts +9 -4
  12. package/dist/types/modes/components/tool-execution.d.ts +12 -1
  13. package/dist/types/modes/components/transcript-container.d.ts +12 -0
  14. package/dist/types/modes/controllers/input-controller.d.ts +9 -1
  15. package/dist/types/modes/theme/theme.d.ts +23 -3
  16. package/dist/types/session/agent-session.d.ts +14 -7
  17. package/dist/types/session/auth-storage.d.ts +1 -1
  18. package/dist/types/session/snapcompact-inline.d.ts +28 -0
  19. package/dist/types/slash-commands/helpers/active-oauth-account.d.ts +14 -0
  20. package/dist/types/system-prompt.d.ts +3 -1
  21. package/dist/types/task/render.d.ts +16 -6
  22. package/dist/types/tools/gh.d.ts +3 -0
  23. package/dist/types/tools/render-utils.d.ts +8 -16
  24. package/dist/types/utils/session-color.d.ts +15 -3
  25. package/dist/types/web/kagi.d.ts +1 -2
  26. package/dist/types/web/search/providers/codex.d.ts +1 -1
  27. package/dist/types/web/search/providers/gemini.d.ts +9 -6
  28. package/package.json +11 -11
  29. package/src/auto-thinking/classifier.ts +1 -5
  30. package/src/commit/model-selection.ts +3 -6
  31. package/src/config/api-key-resolver.ts +10 -3
  32. package/src/config/keybindings.ts +1 -1
  33. package/src/config/model-discovery.ts +60 -46
  34. package/src/config/model-registry.ts +21 -8
  35. package/src/config/model-resolver.ts +57 -3
  36. package/src/config/settings-schema.ts +601 -153
  37. package/src/eval/completion-bridge.ts +1 -5
  38. package/src/export/html/template.generated.ts +1 -1
  39. package/src/export/html/template.js +13 -6
  40. package/src/internal-urls/docs-index.generated.ts +5 -5
  41. package/src/internal-urls/issue-pr-protocol.ts +10 -4
  42. package/src/memories/index.ts +2 -10
  43. package/src/mnemopi/backend.ts +30 -8
  44. package/src/mnemopi/config.ts +6 -1
  45. package/src/mnemopi/state.ts +6 -0
  46. package/src/modes/components/extensions/inspector-panel.ts +6 -2
  47. package/src/modes/components/plan-review-overlay.ts +15 -17
  48. package/src/modes/components/plugin-settings.ts +22 -5
  49. package/src/modes/components/settings-defs.ts +19 -4
  50. package/src/modes/components/settings-selector.ts +493 -93
  51. package/src/modes/components/status-line/component.ts +3 -1
  52. package/src/modes/components/status-line/segments.ts +3 -1
  53. package/src/modes/components/tool-execution.ts +69 -12
  54. package/src/modes/components/transcript-container.ts +26 -0
  55. package/src/modes/components/tree-selector.ts +16 -6
  56. package/src/modes/controllers/command-controller.ts +37 -7
  57. package/src/modes/controllers/event-controller.ts +1 -0
  58. package/src/modes/controllers/input-controller.ts +68 -6
  59. package/src/modes/controllers/selector-controller.ts +81 -61
  60. package/src/modes/interactive-mode.ts +4 -2
  61. package/src/modes/rpc/rpc-mode.ts +2 -1
  62. package/src/modes/shared.ts +2 -0
  63. package/src/modes/theme/theme.ts +100 -7
  64. package/src/modes/utils/context-usage.ts +3 -1
  65. package/src/modes/utils/hotkeys-markdown.ts +1 -1
  66. package/src/modes/utils/ui-helpers.ts +9 -5
  67. package/src/prompts/system/personalities/default.md +26 -0
  68. package/src/prompts/system/personalities/friendly.md +17 -0
  69. package/src/prompts/system/personalities/pragmatic.md +15 -0
  70. package/src/prompts/system/snapcompact-system-frames-note.md +1 -0
  71. package/src/prompts/system/snapcompact-system-stub.md +1 -0
  72. package/src/prompts/system/snapcompact-toolresult-note.md +1 -0
  73. package/src/prompts/system/system-prompt.md +5 -22
  74. package/src/prompts/tools/task.md +3 -3
  75. package/src/sdk.ts +22 -1
  76. package/src/session/agent-session.ts +91 -24
  77. package/src/session/auth-storage.ts +1 -0
  78. package/src/session/session-dump-format.ts +8 -1
  79. package/src/session/session-manager.ts +5 -5
  80. package/src/session/snapcompact-inline.ts +187 -0
  81. package/src/slash-commands/helpers/active-oauth-account.ts +44 -0
  82. package/src/slash-commands/helpers/usage-report.ts +24 -3
  83. package/src/system-prompt.ts +15 -1
  84. package/src/task/render.ts +29 -19
  85. package/src/tool-discovery/tool-index.ts +2 -0
  86. package/src/tools/bash.ts +10 -3
  87. package/src/tools/eval-render.ts +13 -8
  88. package/src/tools/gh.ts +39 -1
  89. package/src/tools/image-gen.ts +114 -78
  90. package/src/tools/inspect-image.ts +1 -5
  91. package/src/tools/job.ts +25 -5
  92. package/src/tools/read.ts +1 -57
  93. package/src/tools/render-utils.ts +29 -31
  94. package/src/tools/ssh.ts +3 -3
  95. package/src/tools/tts.ts +40 -20
  96. package/src/utils/clipboard.ts +56 -4
  97. package/src/utils/commit-message-generator.ts +1 -5
  98. package/src/utils/session-color.ts +83 -9
  99. package/src/utils/title-generator.ts +1 -1
  100. package/src/web/kagi.ts +26 -27
  101. package/src/web/search/providers/codex.ts +42 -40
  102. package/src/web/search/providers/gemini.ts +42 -22
  103. package/src/web/search/providers/perplexity.ts +22 -10
package/src/tools/job.ts CHANGED
@@ -5,6 +5,7 @@ import { prompt } from "@oh-my-pi/pi-utils";
5
5
  import * as z from "zod/v4";
6
6
  import type { AsyncJob, AsyncJobManager } from "../async";
7
7
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
8
+ import { shimmerEnabled, shimmerText } from "../modes/theme/shimmer";
8
9
  import type { Theme } from "../modes/theme/theme";
9
10
  import jobDescription from "../prompts/tools/job.md" with { type: "text" };
10
11
  import { Ellipsis, Hasher, type RenderCache, renderStatusLine, renderTreeList, truncateToWidth } from "../tui";
@@ -493,8 +494,15 @@ export const jobToolRenderer = {
493
494
  render(width: number): readonly string[] {
494
495
  const expanded = options.expanded;
495
496
  const spinnerFrame = options.spinnerFrame ?? 0;
496
- const key = new Hasher().bool(expanded).u32(width).u32(spinnerFrame).digest();
497
- if (cached?.key === key) return cached.lines;
497
+ // Running-job labels shimmer while the poll block is live; the band
498
+ // phase is Date.now()-sampled at render time, so serving cached bytes
499
+ // would pin it to the ~12.5fps spinner-glyph cadence instead of the
500
+ // 30fps redraw. Bypass the cache while any row animates, and key on
501
+ // the animation state so a sealed block never hits stale shimmered
502
+ // bytes (spinnerFrame falls back to 0 on both sides of the seal).
503
+ const shimmerActive = counts.running > 0 && options.spinnerFrame !== undefined && shimmerEnabled();
504
+ const key = new Hasher().bool(expanded).u32(width).u32(spinnerFrame).bool(shimmerActive).digest();
505
+ if (!shimmerActive && cached?.key === key) return cached.lines;
498
506
 
499
507
  const itemLines = renderTreeList<JobSnapshot>(
500
508
  {
@@ -513,7 +521,9 @@ export const jobToolRenderer = {
513
521
  job.status === "running" ? options.spinnerFrame : undefined,
514
522
  );
515
523
  const typeBadge = formatBadge(job.type, statusToColor(job.status), uiTheme);
516
- const idText = uiTheme.fg("muted", job.id);
524
+ // Task jobs label themselves with their agent id, which is also
525
+ // the job id — drop the id column instead of stuttering it twice.
526
+ const idPart = job.label.trim() === job.id ? "" : ` ${uiTheme.fg("muted", job.id)}`;
517
527
  const rawLabelLines = (job.label || "(no label)").split(/\r?\n/);
518
528
  const maxLabelLines = expanded ? LABEL_LINES_EXPANDED : LABEL_LINES_COLLAPSED;
519
529
  const visibleLabelLines = rawLabelLines
@@ -524,8 +534,18 @@ export const jobToolRenderer = {
524
534
  visibleLabelLines[visibleLabelLines.length - 1] = `${last} …`;
525
535
  }
526
536
  const durationText = uiTheme.fg("dim", formatDuration(job.durationMs));
527
- const headLabel = uiTheme.fg("toolOutput", visibleLabelLines[0] ?? "");
528
- lines.push(`${icon} ${idText} ${typeBadge} ${headLabel} ${durationText}`);
537
+ // Running rows in a live block shimmer their label; once the block
538
+ // stops animating (sealed, or a settled snapshot — spinnerFrame
539
+ // cleared) they render static so scrollback never keeps a mid-sweep
540
+ // shimmer band.
541
+ const live = job.status === "running" && options.spinnerFrame !== undefined;
542
+ const headRaw = visibleLabelLines[0] ?? "";
543
+ const headLabel = live
544
+ ? shimmerEnabled()
545
+ ? shimmerText(headRaw, uiTheme)
546
+ : uiTheme.fg("accent", headRaw)
547
+ : uiTheme.fg("toolOutput", headRaw);
548
+ lines.push(`${icon}${idPart} ${typeBadge} ${headLabel} ${durationText}`);
529
549
  for (let i = 1; i < visibleLabelLines.length; i++) {
530
550
  lines.push(` ${uiTheme.fg("toolOutput", visibleLabelLines[i]!)}`);
531
551
  }
package/src/tools/read.ts CHANGED
@@ -736,17 +736,6 @@ interface ResolvedSqliteReadPath {
736
736
  /** Per-execute memo of suffix-glob lookups; `null` records a confirmed miss. */
737
737
  type SuffixMatchCache = Map<string, { absolutePath: string; displayPath: string } | null>;
738
738
 
739
- /**
740
- * Repeated whole-file reads of the same path pin stale copies in context.
741
- * From this per-session read count onward, file reads carry a trailing nudge
742
- * to prefer narrower re-reads.
743
- */
744
- const REPEAT_READ_NOTICE_THRESHOLD = 3;
745
-
746
- function formatRepeatReadNotice(count: number): string {
747
- return `[note: read #${count} of this file this session — after edits, prefer the context echoed in the edit result or a narrow range re-read]`;
748
- }
749
-
750
739
  /**
751
740
  * Read tool implementation.
752
741
  *
@@ -765,8 +754,6 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
765
754
  readonly #autoResizeImages: boolean;
766
755
  readonly #defaultLimit: number;
767
756
  readonly #inspectImageEnabled: boolean;
768
- /** Successful file reads per resolved base path (selector stripped) this session. */
769
- readonly #readCounts = new Map<string, number>();
770
757
 
771
758
  constructor(private readonly session: ToolSession) {
772
759
  const displayMode = resolveFileDisplayMode(session);
@@ -785,19 +772,6 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
785
772
  });
786
773
  }
787
774
 
788
- /**
789
- * Count a file read of `absolutePath` and return the repeat-read nudge once
790
- * the per-session count reaches {@link REPEAT_READ_NOTICE_THRESHOLD}.
791
- * Non-file sources (URLs, internal resources, directories, archives,
792
- * SQLite, images) are never counted.
793
- */
794
- #repeatReadNotice(absolutePath: string): string | undefined {
795
- const count = (this.#readCounts.get(absolutePath) ?? 0) + 1;
796
- this.#readCounts.set(absolutePath, count);
797
- if (count < REPEAT_READ_NOTICE_THRESHOLD) return undefined;
798
- return formatRepeatReadNotice(count);
799
- }
800
-
801
775
  async #tryReadDelimitedPaths(
802
776
  readPath: string,
803
777
  signal?: AbortSignal,
@@ -974,8 +948,6 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
974
948
  ignoreResultLimits?: boolean;
975
949
  raw?: boolean;
976
950
  immutable?: boolean;
977
- /** Trailing repeat-read nudge; appended at the very end of the text. */
978
- repeatNotice?: string;
979
951
  },
980
952
  ): AgentToolResult<ReadToolDetails> {
981
953
  const displayMode = resolveFileDisplayMode(this.session, { raw: options.raw, immutable: options.immutable });
@@ -1120,9 +1092,6 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1120
1092
  : formatLineEntries(buildLineEntries(endLine), startLineDisplay);
1121
1093
  }
1122
1094
 
1123
- if (options.repeatNotice) {
1124
- outputText += `\n${options.repeatNotice}`;
1125
- }
1126
1095
  resultBuilder.text(outputText);
1127
1096
  if (truncationInfo) {
1128
1097
  resultBuilder.truncation(truncationInfo.result, truncationInfo.options);
@@ -1148,8 +1117,6 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1148
1117
  entityLabel: string;
1149
1118
  raw?: boolean;
1150
1119
  immutable?: boolean;
1151
- /** Trailing repeat-read nudge; appended at the very end of the text. */
1152
- repeatNotice?: string;
1153
1120
  },
1154
1121
  ): AgentToolResult<ReadToolDetails> {
1155
1122
  const displayMode = resolveFileDisplayMode(this.session, { raw: options.raw, immutable: options.immutable });
@@ -1210,11 +1177,8 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1210
1177
  const bound = range.endLine !== undefined ? `${range.startLine}-${range.endLine}` : `${range.startLine}`;
1211
1178
  notices.push(`[Range ${bound} is beyond end of ${options.entityLabel} (${totalLines} lines total); skipped]`);
1212
1179
  }
1213
- let finalText =
1180
+ const finalText =
1214
1181
  notices.length > 0 ? (outputText ? `${outputText}\n${notices.join("\n")}` : notices.join("\n")) : outputText;
1215
- if (options.repeatNotice) {
1216
- finalText = finalText ? `${finalText}\n${options.repeatNotice}` : options.repeatNotice;
1217
- }
1218
1182
  resultBuilder.text(finalText);
1219
1183
  return resultBuilder.done();
1220
1184
  }
@@ -1232,7 +1196,6 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1232
1196
  parsed: ParsedSelector,
1233
1197
  displayMode: { hashLines: boolean; lineNumbers: boolean },
1234
1198
  suffixResolution: { from: string; to: string } | undefined,
1235
- repeatNotice: string | undefined,
1236
1199
  signal: AbortSignal | undefined,
1237
1200
  ): Promise<{
1238
1201
  outputText: string;
@@ -1252,7 +1215,6 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1252
1215
  sourcePath: absolutePath,
1253
1216
  entityLabel: "file",
1254
1217
  raw: rawSelector,
1255
- repeatNotice,
1256
1218
  });
1257
1219
  if (suffixResolution) {
1258
1220
  const notice = `[Path '${suffixResolution.from}' not found; resolved to '${suffixResolution.to}' via suffix match]`;
@@ -1934,7 +1896,6 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1934
1896
  let details: ReadToolDetails = {};
1935
1897
  let sourcePath: string | undefined;
1936
1898
  let columnTruncated = 0;
1937
- let repeatNotice: string | undefined;
1938
1899
  let truncationInfo:
1939
1900
  | { result: TruncationResult; options: { direction: "head"; startLine?: number; totalFileLines?: number } }
1940
1901
  | undefined;
@@ -1999,13 +1960,11 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1999
1960
  }
2000
1961
  } else if (isNotebookPath(absolutePath) && !isRawSelector(parsed)) {
2001
1962
  const notebookText = await readEditableNotebookText(absolutePath, localReadPath);
2002
- repeatNotice = this.#repeatReadNotice(absolutePath);
2003
1963
  if (isMultiRange(parsed) && parsed.kind === "lines") {
2004
1964
  return this.#buildInMemoryMultiRangeResult(notebookText, parsed.ranges, {
2005
1965
  details: { resolvedPath: absolutePath },
2006
1966
  sourcePath: absolutePath,
2007
1967
  entityLabel: "notebook",
2008
- repeatNotice,
2009
1968
  });
2010
1969
  }
2011
1970
  const { offset, limit } = selToOffsetLimit(parsed);
@@ -2013,13 +1972,11 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
2013
1972
  details: { resolvedPath: absolutePath },
2014
1973
  sourcePath: absolutePath,
2015
1974
  entityLabel: "notebook",
2016
- repeatNotice,
2017
1975
  });
2018
1976
  } else if (shouldConvertWithMarkit) {
2019
1977
  // Convert document via markit.
2020
1978
  const result = await convertFileWithMarkit(absolutePath, signal);
2021
1979
  if (result.ok) {
2022
- repeatNotice = this.#repeatReadNotice(absolutePath);
2023
1980
  // Route the converted markdown through the in-memory text builder
2024
1981
  // so line-range selectors (`file.pdf:50-100`, `:5-16,40-80`) and
2025
1982
  // raw mode apply against the converted output. Without this,
@@ -2030,7 +1987,6 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
2030
1987
  details: { resolvedPath: absolutePath },
2031
1988
  sourcePath: absolutePath,
2032
1989
  entityLabel: "document",
2033
- repeatNotice,
2034
1990
  });
2035
1991
  }
2036
1992
  const { offset, limit } = selToOffsetLimit(parsed);
@@ -2039,7 +1995,6 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
2039
1995
  sourcePath: absolutePath,
2040
1996
  entityLabel: "document",
2041
1997
  raw: isRawSelector(parsed),
2042
- repeatNotice,
2043
1998
  });
2044
1999
  } else if (result.error) {
2045
2000
  content = [{ type: "text", text: `[Cannot read ${ext} file: ${result.error || "conversion failed"}]` }];
@@ -2047,7 +2002,6 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
2047
2002
  content = [{ type: "text", text: `[Cannot read ${ext} file: conversion failed]` }];
2048
2003
  }
2049
2004
  } else {
2050
- repeatNotice = this.#repeatReadNotice(absolutePath);
2051
2005
  if (
2052
2006
  parsed.kind === "none" &&
2053
2007
  this.session.settings.get("read.summarize.enabled") &&
@@ -2089,7 +2043,6 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
2089
2043
  parsed,
2090
2044
  displayMode,
2091
2045
  suffixResolution,
2092
- repeatNotice,
2093
2046
  undefined, // plain-file read: deterministic and fast, never abort mid-read
2094
2047
  );
2095
2048
  if (multiResult.bridgeResult) return multiResult.bridgeResult;
@@ -2113,7 +2066,6 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
2113
2066
  sourcePath: absolutePath,
2114
2067
  entityLabel: "file",
2115
2068
  raw: isRawSelector(parsed),
2116
- repeatNotice,
2117
2069
  });
2118
2070
  if (suffixResolution) {
2119
2071
  const notice = `[Path '${suffixResolution.from}' not found; resolved to '${suffixResolution.to}' via suffix match]`;
@@ -2415,14 +2367,6 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
2415
2367
  content = [{ type: "text", text: notice }, ...content];
2416
2368
  }
2417
2369
  }
2418
- if (repeatNotice) {
2419
- // Trailing nudge goes at the very end of the textual result so it never
2420
- // disturbs hashline tag headers or inline notices.
2421
- const lastText = content.findLast((c): c is TextContent => c.type === "text");
2422
- if (lastText) {
2423
- lastText.text = `${lastText.text}\n${repeatNotice}`;
2424
- }
2425
- }
2426
2370
  const resultBuilder = toolResult(details).content(content);
2427
2371
  if (sourcePath) {
2428
2372
  resultBuilder.sourcePath(sourcePath);
@@ -183,24 +183,32 @@ export function formatMoreItems(remaining: number, itemType: string): string {
183
183
  }
184
184
 
185
185
  /**
186
- * Maximum rows a tool's streaming/pending *call* preview may render before it is
187
- * capped. This is intentionally conservative: the preview still sits inside a
188
- * transcript that already consumed some viewport rows, and tool blocks carry
189
- * extra chrome (status/header/border/"more lines"), so a "reasonable" raw code
190
- * or command preview like 10-12 lines can still overflow and strand its top
191
- * while the block is volatile. Keeping the live call window short avoids that
192
- * across terminals without turning the transcript into an interactive scroller.
186
+ * Collapsed command/code previews render a tail window sized from the live
187
+ * viewport: terminal rows minus a reserve for the rest of the block (frame,
188
+ * Output section, stats line) and the editor/status area below the
189
+ * transcript. This keeps a volatile streaming block from growing past the
190
+ * viewport and stranding its top, while letting tall terminals show more.
193
191
  */
194
- export const CALL_PREVIEW_MAX_LINES = 6;
192
+ const PREVIEW_WINDOW_RESERVED_ROWS = 20;
193
+ /** Floor so tiny or unknown viewports still show a useful window. */
194
+ const PREVIEW_WINDOW_MIN_LINES = 6;
195
+ /** Assumed viewport when rows are unknown (non-TTY, tests). */
196
+ const PREVIEW_WINDOW_FALLBACK_ROWS = 30;
197
+
198
+ /** Tail-window height for collapsed command/code previews. */
199
+ export function previewWindowRows(): number {
200
+ const rows = process.stdout.rows || PREVIEW_WINDOW_FALLBACK_ROWS;
201
+ return Math.max(PREVIEW_WINDOW_MIN_LINES, rows - PREVIEW_WINDOW_RESERVED_ROWS);
202
+ }
195
203
 
196
204
  /**
197
- * Cap a pre-rendered pending/call preview to a bounded window. When truncated,
198
- * show both the head and the live tail so the user can still see what the tool
199
- * is currently writing while the volatile block stays short enough not to strand
200
- * its top above the viewport. `Ctrl+O` widens the bounded window, but does not
201
- * fully uncap live tool previews for the same reason.
205
+ * Cap a pre-rendered command preview to a viewport-sized tail window: the end
206
+ * of the command stays visible (it is the live edge while args stream) behind
207
+ * an "… N earlier lines" marker on top. The same window applies while
208
+ * streaming and after completion so the block never jumps; only `expanded`
209
+ * (ctrl+o) uncaps it.
202
210
  *
203
- * `prefix` (raw, e.g. a dim tree gutter) is prepended to the summary line so
211
+ * `prefix` (raw, e.g. a dim tree gutter) is prepended to the marker line so
204
212
  * nested previews stay aligned.
205
213
  */
206
214
  export function capPreviewLines(
@@ -208,24 +216,14 @@ export function capPreviewLines(
208
216
  theme: Theme,
209
217
  options: { max?: number; expanded?: boolean; prefix?: string } = {},
210
218
  ): string[] {
211
- const max = options.max ?? (options.expanded ? PREVIEW_LIMITS.EXPANDED_LINES : CALL_PREVIEW_MAX_LINES);
219
+ if (options.expanded) return lines;
220
+ const max = options.max ?? previewWindowRows();
212
221
  if (lines.length <= max) return lines;
213
- if (max <= 1) {
214
- const hint = formatExpandHint(theme, options.expanded, true);
215
- const moreLine = `${formatMoreItems(lines.length, "line")}${hint ? ` ${hint}` : ""}`;
216
- return [`${options.prefix ?? ""}${theme.fg("dim", moreLine)}`];
217
- }
218
- const bodyBudget = max - 1; // reserve one summary row
219
- const headCount = Math.max(1, Math.ceil(bodyBudget / 2));
220
- const tailCount = Math.max(1, bodyBudget - headCount);
221
- const hidden = Math.max(0, lines.length - headCount - tailCount);
222
- const hint = formatExpandHint(theme, options.expanded, true);
223
- const moreLine = `${formatMoreItems(hidden, "line")}${hint ? ` ${hint}` : ""}`;
224
- return [
225
- ...lines.slice(0, headCount),
226
- `${options.prefix ?? ""}${theme.fg("dim", moreLine)}`,
227
- ...lines.slice(lines.length - tailCount),
228
- ];
222
+ const visible = max <= 1 ? [] : lines.slice(lines.length - (max - 1));
223
+ const hidden = lines.length - visible.length;
224
+ const hint = formatExpandHint(theme, false, true);
225
+ const marker = `… ${hidden} earlier ${pluralize("line", hidden)}${hint ? ` ${hint}` : ""}`;
226
+ return [`${options.prefix ?? ""}${theme.fg("dim", marker)}`, ...visible];
229
227
  }
230
228
 
231
229
  export function formatMeta(meta: string[], theme: Theme): string {
package/src/tools/ssh.ts CHANGED
@@ -329,9 +329,9 @@ export const sshToolRenderer = {
329
329
  state: "success",
330
330
  sections: [
331
331
  {
332
- lines: options.isPartial
333
- ? capPreviewLines(cmdLines, uiTheme, { expanded: options.expanded })
334
- : cmdLines,
332
+ // Viewport-sized tail window in every state — streaming and final
333
+ // render identically; only ctrl+o uncaps.
334
+ lines: capPreviewLines(cmdLines, uiTheme, { expanded }),
335
335
  },
336
336
  { label: uiTheme.fg("toolTitle", "Output"), lines: outputLines },
337
337
  ],
package/src/tools/tts.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  // Ported from NousResearch/hermes-agent (MIT) — tools/tts_tool.py L167-171, L896-959.
2
2
 
3
3
  import type { AgentToolResult } from "@oh-my-pi/pi-agent-core";
4
+ import { type ApiKey, ProviderHttpError, withAuth } from "@oh-my-pi/pi-ai";
4
5
  import * as z from "zod/v4";
5
6
  import type { CustomTool, CustomToolContext } from "../extensibility/custom-tools/types";
6
7
  import { ohMyPiXAIUserAgent, resolveXAIHttpCredentials } from "../lib/xai-http";
@@ -96,27 +97,46 @@ export const ttsTool: CustomTool<typeof ttsSchema, TtsToolDetails> = {
96
97
  const timeoutSignal = AbortSignal.timeout(60_000);
97
98
  const combinedSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
98
99
 
99
- const response = await fetch(`${creds.baseURL}/tts`, {
100
- method: "POST",
101
- headers: {
102
- Authorization: `Bearer ${creds.apiKey}`,
103
- "Content-Type": "application/json",
104
- "User-Agent": ohMyPiXAIUserAgent(),
105
- },
106
- body: JSON.stringify(payload),
107
- signal: combinedSignal,
100
+ const sessionId = ctx.sessionManager.getSessionId();
101
+ const apiKey: ApiKey = ctx.modelRegistry.resolver(creds.provider, {
102
+ sessionId,
103
+ baseUrl: creds.baseURL,
108
104
  });
109
- if (!response.ok) {
110
- const detail = await response.text();
111
- return {
112
- isError: true,
113
- content: [
114
- {
115
- type: "text",
116
- text: `xAI TTS failed (${response.status}): ${detail.slice(0, 300)}`,
117
- },
118
- ],
119
- };
105
+
106
+ let response: Response;
107
+ try {
108
+ response = await withAuth(
109
+ apiKey,
110
+ async key => {
111
+ const resp = await fetch(`${creds.baseURL}/tts`, {
112
+ method: "POST",
113
+ headers: {
114
+ Authorization: `Bearer ${key}`,
115
+ "Content-Type": "application/json",
116
+ "User-Agent": ohMyPiXAIUserAgent(),
117
+ },
118
+ body: JSON.stringify(payload),
119
+ signal: combinedSignal,
120
+ });
121
+ if (!resp.ok) {
122
+ const detail = await resp.text();
123
+ throw new ProviderHttpError(`xAI TTS failed (${resp.status}): ${detail.slice(0, 300)}`, resp.status, {
124
+ headers: resp.headers,
125
+ });
126
+ }
127
+ return resp;
128
+ },
129
+ { signal: combinedSignal },
130
+ );
131
+ } catch (error) {
132
+ const status = (error as { status?: unknown }).status;
133
+ if (error instanceof Error && typeof status === "number") {
134
+ return {
135
+ isError: true,
136
+ content: [{ type: "text", text: error.message }],
137
+ };
138
+ }
139
+ throw error;
120
140
  }
121
141
  const bytes = new Uint8Array(await response.arrayBuffer());
122
142
  await Bun.write(outputPath, bytes);
@@ -125,6 +125,56 @@ async function readImageViaPowerShell(): Promise<ClipboardImage | null> {
125
125
  }
126
126
  }
127
127
 
128
+ // PowerShell one-liner that emits the clipboard text verbatim on stdout, or
129
+ // nothing when the clipboard holds no text. `[Console]::Out.Write` avoids the
130
+ // trailing newline Write-Output would add; output encoding is forced to UTF-8
131
+ // so non-ASCII text survives the interop boundary regardless of console
132
+ // codepage.
133
+ const POWERSHELL_TEXT_SCRIPT = `
134
+ $ErrorActionPreference = 'Stop'
135
+ [Console]::OutputEncoding = [Text.Encoding]::UTF8
136
+ [Console]::Out.Write([string](Get-Clipboard -Raw))
137
+ `;
138
+
139
+ /**
140
+ * Read clipboard text through Windows PowerShell — native win32 or the WSL
141
+ * host over interop.
142
+ *
143
+ * Same rationale as `readImageViaPowerShell`: under WSL, the WSLg Wayland
144
+ * clipboard only works when `wl-clipboard` happens to be installed in the
145
+ * distro, while `powershell.exe` is always reachable. Forcing UTF-8 output
146
+ * encoding keeps non-ASCII text intact regardless of the console codepage
147
+ * (the legacy win32 `Get-Clipboard` shell-out mangled it), and `Bun.spawn`
148
+ * keeps a cold PowerShell start off the TUI event loop.
149
+ *
150
+ * Returns null when the bridge fails (WSL callers fall through to
151
+ * wl-paste/xclip); an empty string is a successful "no text" read.
152
+ */
153
+ async function readTextViaPowerShell(): Promise<string | null> {
154
+ try {
155
+ const proc = Bun.spawn(["powershell.exe", "-NoProfile", "-NonInteractive", "-Command", POWERSHELL_TEXT_SCRIPT], {
156
+ stdout: "pipe",
157
+ stderr: "ignore",
158
+ stdin: "ignore",
159
+ });
160
+ const timer = setTimeout(() => proc.kill(), POWERSHELL_TIMEOUT_MS);
161
+ let stdout = "";
162
+ try {
163
+ stdout = await new Response(proc.stdout).text();
164
+ await proc.exited;
165
+ } catch (err) {
166
+ logger.warn("clipboard: powershell text read failed", { error: String(err) });
167
+ return null;
168
+ } finally {
169
+ clearTimeout(timer);
170
+ }
171
+ if (proc.exitCode !== 0) return null;
172
+ return stdout.replaceAll("\r\n", "\n");
173
+ } catch {
174
+ return null;
175
+ }
176
+ }
177
+
128
178
  /**
129
179
  * Read an image from the system clipboard.
130
180
  *
@@ -165,14 +215,16 @@ export async function readTextFromClipboard(): Promise<string> {
165
215
  return execSync("pbpaste", { encoding: "utf8", timeout: 2000 }).toString();
166
216
  }
167
217
  if (p === "win32") {
168
- return execSync('powershell.exe -NoProfile -Command "Get-Clipboard"', {
169
- encoding: "utf8",
170
- timeout: 2000,
171
- }).toString();
218
+ return (await readTextViaPowerShell()) ?? "";
172
219
  }
173
220
  if (process.env.TERMUX_VERSION) {
174
221
  return execSync("termux-clipboard-get", { encoding: "utf8", timeout: 2000 }).toString();
175
222
  }
223
+ if (isWsl()) {
224
+ const text = await readTextViaPowerShell();
225
+ if (text !== null) return text;
226
+ // Bridge failed — fall through to the wl-paste/xclip paths below.
227
+ }
176
228
  const hasWaylandDisplay = Boolean(process.env.WAYLAND_DISPLAY);
177
229
  const hasX11Display = Boolean(process.env.DISPLAY);
178
230
  if (hasWaylandDisplay) {
@@ -112,11 +112,7 @@ export async function generateCommitMessage(
112
112
  messages: [{ role: "user", content: userMessage, timestamp: Date.now() }],
113
113
  },
114
114
  {
115
- apiKey: registry.resolver(candidate.model.provider, {
116
- sessionId,
117
- baseUrl: candidate.model.baseUrl,
118
- modelId: candidate.model.id,
119
- }),
115
+ apiKey: registry.resolver(candidate.model, sessionId),
120
116
  maxTokens,
121
117
  reasoning: toReasoningEffort(candidate.thinkingLevel),
122
118
  },
@@ -1,4 +1,4 @@
1
- import { hslToHex, relativeLuminance } from "@oh-my-pi/pi-utils";
1
+ import { hexToHsv, hslToHex, relativeLuminance } from "@oh-my-pi/pi-utils";
2
2
 
3
3
  /**
4
4
  * Derive a stable hue (0-359) from a string using djb2 hash.
@@ -25,23 +25,97 @@ function accentLuminanceCap(surfaceLuminance: number): number {
25
25
  return Math.max(0, (surfaceLuminance + 0.05) / ACCENT_MIN_CONTRAST - 0.05);
26
26
  }
27
27
 
28
+ /** Minimum angular distance in hue degrees from any theme color to avoid visual collision. */
29
+ const MIN_HUE_DISTANCE = 10;
30
+ /** Saturation threshold below which hue is meaningless (near-gray). */
31
+ const MIN_SATURATION_FOR_HUE = 0.1;
32
+
33
+ /** Angular distance between two hue values (0-360). */
34
+ function hueDistance(a: number, b: number): number {
35
+ const d = Math.abs(a - b);
36
+ return Math.min(d, 360 - d);
37
+ }
38
+
39
+ /**
40
+ * Parse hue (0-360) from a hex color string.
41
+ * Returns undefined for near-gray colors where hue is not meaningful.
42
+ */
43
+ function hexToHue(hex: string): number | undefined {
44
+ const hsv = hexToHsv(hex);
45
+ if (hsv.s < MIN_SATURATION_FOR_HUE) return undefined;
46
+ return hsv.h;
47
+ }
48
+
49
+ /**
50
+ * Find a hue at least {@link MIN_HUE_DISTANCE} from all occupied hues,
51
+ * clamped to [lo, hi] to prevent leaving the intended hue band.
52
+ * Returns `target` unchanged if no safe hue exists within bounds.
53
+ */
54
+ function findSafeHue(target: number, occupied: number[], lo: number, hi: number): number {
55
+ if (occupied.length === 0) return target;
56
+ if (occupied.every(h => hueDistance(target, h) >= MIN_HUE_DISTANCE)) {
57
+ return target;
58
+ }
59
+ for (let d = 1; d <= hi - lo; d++) {
60
+ for (const dir of [1, -1]) {
61
+ const candidate = Math.max(lo, Math.min(hi, target + d * dir));
62
+ if (occupied.every(h => hueDistance(candidate, h) >= MIN_HUE_DISTANCE)) {
63
+ return candidate;
64
+ }
65
+ }
66
+ }
67
+ // fallback: keep the original target if no safe spot exists within the band
68
+ return target;
69
+ }
70
+
71
+ /** Hue range low and high for dark themes (warm: red → yellow → green). */
72
+ const DARK_HUE_START = 0;
73
+ const DARK_HUE_END = 120;
74
+ /** Hue range low and high for light themes (cool: cyan → blue → purple). */
75
+ const LIGHT_HUE_START = 180;
76
+ const LIGHT_HUE_END = 300;
77
+
28
78
  /**
29
- * Derive a stable CSS hex accent color from a session name.
79
+ * Derive a stable CSS hex accent color from a session name and the active theme.
80
+ *
81
+ * Picks a hue from a **dark/light-specific range** so the accent feels natural
82
+ * for the theme type (warm on dark, cool on light). The session name hash
83
+ * determines the exact hue within the range. The result is checked against
84
+ * all theme color hues and shifted if it lands within {@link MIN_HUE_DISTANCE}
85
+ * of an existing theme hue, but is clamped to the hue band so it never
86
+ * drifts into an unrelated part of the spectrum.
30
87
  *
31
88
  * On dark themes (`surfaceLuminance` undefined) the accent is vivid (high
32
89
  * saturation, high lightness). On light themes the lightness is reduced until the
33
90
  * accent's perceived luminance clears {@link ACCENT_MIN_CONTRAST} against the
34
91
  * actual surface it renders on — so it stays legible on near-white *and* mid-light
35
- * backgrounds — while keeping the same per-session hue.
92
+ * backgrounds.
93
+ *
94
+ * @param name — session name for per-session uniqueness.
95
+ * @param themeColorHexes — all theme colors to check collision against.
96
+ * @param surfaceLuminance — undefined on dark themes; WCAG luminance of the
97
+ * status-line background on light themes.
36
98
  */
37
- export function getSessionAccentHex(name: string, surfaceLuminance?: number): string {
38
- const hue = nameToHue(name);
99
+ export function getSessionAccentHex(name: string, themeColorHexes: string[], surfaceLuminance?: number): string {
100
+ // 1. Pick hue range based on theme mode
101
+ const hueStart = surfaceLuminance === undefined ? DARK_HUE_START : LIGHT_HUE_START;
102
+ const hueEnd = surfaceLuminance === undefined ? DARK_HUE_END : LIGHT_HUE_END;
103
+ const range = hueEnd - hueStart;
104
+
105
+ // 2. Session name picks within the range
106
+ let targetHue = hueStart + (nameToHue(name) % range);
107
+
108
+ // 3. Shift away if too close to any theme color — stays within [hueStart, hueEnd]
109
+ const themeHues = themeColorHexes.map(hexToHue).filter((h): h is number => h !== undefined);
110
+ targetHue = findSafeHue(targetHue, themeHues, hueStart, hueEnd);
111
+
112
+ // 4. Lightness/contrast — vivid on dark, bisected for AA on light
39
113
  if (surfaceLuminance === undefined) {
40
- return hslToHex(hue, ACCENT_SATURATION, ACCENT_DARK_LIGHTNESS);
114
+ return hslToHex(targetHue, ACCENT_SATURATION, ACCENT_DARK_LIGHTNESS);
41
115
  }
42
116
 
43
117
  const cap = accentLuminanceCap(surfaceLuminance);
44
- const top = hslToHex(hue, ACCENT_SATURATION, ACCENT_DARK_LIGHTNESS);
118
+ const top = hslToHex(targetHue, ACCENT_SATURATION, ACCENT_DARK_LIGHTNESS);
45
119
  if ((relativeLuminance(top) ?? 0) <= cap) return top;
46
120
 
47
121
  // Bisect lightness: `lo` always yields luminance <= cap, `hi` always above it.
@@ -49,13 +123,13 @@ export function getSessionAccentHex(name: string, surfaceLuminance?: number): st
49
123
  let hi = ACCENT_DARK_LIGHTNESS;
50
124
  for (let i = 0; i < 20; i++) {
51
125
  const mid = (lo + hi) / 2;
52
- if ((relativeLuminance(hslToHex(hue, ACCENT_SATURATION, mid)) ?? 0) > cap) {
126
+ if ((relativeLuminance(hslToHex(targetHue, ACCENT_SATURATION, mid)) ?? 0) > cap) {
53
127
  hi = mid;
54
128
  } else {
55
129
  lo = mid;
56
130
  }
57
131
  }
58
- return hslToHex(hue, ACCENT_SATURATION, lo);
132
+ return hslToHex(targetHue, ACCENT_SATURATION, lo);
59
133
  }
60
134
 
61
135
  /**
@@ -258,7 +258,7 @@ export async function generateTitleOnline(
258
258
  tools: [setTitleTool],
259
259
  },
260
260
  {
261
- apiKey: registry.resolver(model.provider, { sessionId, baseUrl: model.baseUrl, modelId: model.id }),
261
+ apiKey: registry.resolver(model, sessionId),
262
262
  maxTokens,
263
263
  disableReasoning: true,
264
264
  toolChoice: { type: "tool", name: SET_TITLE_TOOL_NAME },