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

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 (135) hide show
  1. package/CHANGELOG.md +107 -0
  2. package/dist/cli.js +692 -607
  3. package/dist/types/cli/usage-cli.d.ts +10 -1
  4. package/dist/types/commands/usage.d.ts +9 -0
  5. package/dist/types/config/api-key-resolver.d.ts +9 -3
  6. package/dist/types/config/keybindings.d.ts +1 -1
  7. package/dist/types/config/model-discovery.d.ts +6 -4
  8. package/dist/types/config/model-registry.d.ts +7 -4
  9. package/dist/types/config/settings-schema.d.ts +508 -155
  10. package/dist/types/export/html/template.generated.d.ts +1 -1
  11. package/dist/types/mnemopi/config.d.ts +3 -1
  12. package/dist/types/modes/components/reset-usage-selector.d.ts +12 -0
  13. package/dist/types/modes/components/session-selector.d.ts +1 -1
  14. package/dist/types/modes/components/settings-defs.d.ts +9 -2
  15. package/dist/types/modes/components/settings-selector.d.ts +9 -4
  16. package/dist/types/modes/components/tool-execution.d.ts +26 -1
  17. package/dist/types/modes/components/transcript-container.d.ts +12 -0
  18. package/dist/types/modes/controllers/input-controller.d.ts +9 -1
  19. package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
  20. package/dist/types/modes/interactive-mode.d.ts +10 -0
  21. package/dist/types/modes/session-observer-registry.d.ts +2 -0
  22. package/dist/types/modes/theme/theme.d.ts +23 -3
  23. package/dist/types/modes/types.d.ts +2 -0
  24. package/dist/types/modes/utils/context-usage.d.ts +6 -1
  25. package/dist/types/session/agent-session.d.ts +28 -8
  26. package/dist/types/session/auth-storage.d.ts +1 -1
  27. package/dist/types/session/codex-auto-reset.d.ts +107 -0
  28. package/dist/types/session/snapcompact-inline.d.ts +129 -0
  29. package/dist/types/slash-commands/helpers/active-oauth-account.d.ts +14 -0
  30. package/dist/types/slash-commands/helpers/reset-usage.d.ts +27 -0
  31. package/dist/types/system-prompt.d.ts +3 -1
  32. package/dist/types/task/render.d.ts +17 -6
  33. package/dist/types/tools/gh.d.ts +3 -0
  34. package/dist/types/tools/render-utils.d.ts +8 -16
  35. package/dist/types/tools/todo.d.ts +0 -11
  36. package/dist/types/utils/session-color.d.ts +15 -3
  37. package/dist/types/web/kagi.d.ts +1 -2
  38. package/dist/types/web/search/providers/codex.d.ts +1 -1
  39. package/dist/types/web/search/providers/gemini.d.ts +9 -6
  40. package/package.json +11 -11
  41. package/src/auto-thinking/classifier.ts +1 -5
  42. package/src/cli/usage-cli.ts +187 -16
  43. package/src/commands/usage.ts +8 -0
  44. package/src/commit/model-selection.ts +3 -6
  45. package/src/config/api-key-resolver.ts +10 -3
  46. package/src/config/keybindings.ts +1 -1
  47. package/src/config/model-discovery.ts +60 -46
  48. package/src/config/model-registry.ts +21 -8
  49. package/src/config/model-resolver.ts +57 -3
  50. package/src/config/settings-schema.ts +654 -153
  51. package/src/config/settings.ts +9 -0
  52. package/src/eval/completion-bridge.ts +1 -5
  53. package/src/export/html/template.generated.ts +1 -1
  54. package/src/export/html/template.js +13 -6
  55. package/src/internal-urls/docs-index.generated.ts +6 -6
  56. package/src/internal-urls/issue-pr-protocol.ts +10 -4
  57. package/src/memories/index.ts +2 -10
  58. package/src/mnemopi/backend.ts +30 -8
  59. package/src/mnemopi/config.ts +6 -1
  60. package/src/mnemopi/state.ts +6 -0
  61. package/src/modes/components/extensions/inspector-panel.ts +6 -2
  62. package/src/modes/components/plan-review-overlay.ts +15 -17
  63. package/src/modes/components/plugin-settings.ts +22 -5
  64. package/src/modes/components/reset-usage-selector.ts +161 -0
  65. package/src/modes/components/session-selector.ts +8 -2
  66. package/src/modes/components/settings-defs.ts +19 -4
  67. package/src/modes/components/settings-selector.ts +510 -95
  68. package/src/modes/components/status-line/component.ts +3 -1
  69. package/src/modes/components/status-line/segments.ts +3 -1
  70. package/src/modes/components/tool-execution.ts +87 -12
  71. package/src/modes/components/transcript-container.ts +49 -1
  72. package/src/modes/components/tree-selector.ts +16 -6
  73. package/src/modes/controllers/command-controller.ts +61 -8
  74. package/src/modes/controllers/event-controller.ts +1 -0
  75. package/src/modes/controllers/input-controller.ts +68 -6
  76. package/src/modes/controllers/selector-controller.ts +149 -61
  77. package/src/modes/interactive-mode.ts +63 -2
  78. package/src/modes/rpc/rpc-mode.ts +2 -1
  79. package/src/modes/session-observer-registry.ts +61 -3
  80. package/src/modes/shared.ts +2 -0
  81. package/src/modes/theme/theme.ts +102 -9
  82. package/src/modes/types.ts +2 -0
  83. package/src/modes/utils/context-usage.ts +78 -2
  84. package/src/modes/utils/hotkeys-markdown.ts +1 -1
  85. package/src/modes/utils/ui-helpers.ts +9 -5
  86. package/src/prompts/system/personalities/default.md +26 -0
  87. package/src/prompts/system/personalities/friendly.md +17 -0
  88. package/src/prompts/system/personalities/pragmatic.md +15 -0
  89. package/src/prompts/system/snapcompact-context-frames-note.md +1 -0
  90. package/src/prompts/system/snapcompact-context-stub.md +1 -0
  91. package/src/prompts/system/snapcompact-system-frames-note.md +1 -0
  92. package/src/prompts/system/snapcompact-system-stub.md +1 -0
  93. package/src/prompts/system/snapcompact-toolresult-note.md +1 -0
  94. package/src/prompts/system/system-prompt.md +5 -22
  95. package/src/prompts/tools/browser.md +33 -43
  96. package/src/prompts/tools/eval.md +27 -50
  97. package/src/prompts/tools/irc.md +29 -31
  98. package/src/prompts/tools/read.md +31 -37
  99. package/src/prompts/tools/task.md +3 -3
  100. package/src/prompts/tools/todo.md +1 -2
  101. package/src/sdk.ts +23 -1
  102. package/src/session/agent-session.ts +221 -29
  103. package/src/session/auth-storage.ts +4 -0
  104. package/src/session/codex-auto-reset.ts +190 -0
  105. package/src/session/session-dump-format.ts +8 -1
  106. package/src/session/session-manager.ts +5 -5
  107. package/src/session/snapcompact-inline.ts +524 -0
  108. package/src/slash-commands/builtin-registry.ts +145 -8
  109. package/src/slash-commands/helpers/active-oauth-account.ts +44 -0
  110. package/src/slash-commands/helpers/context-report.ts +28 -1
  111. package/src/slash-commands/helpers/reset-usage.ts +66 -0
  112. package/src/slash-commands/helpers/usage-report.ts +36 -3
  113. package/src/system-prompt.ts +15 -1
  114. package/src/task/index.ts +30 -7
  115. package/src/task/render.ts +57 -32
  116. package/src/tool-discovery/tool-index.ts +2 -0
  117. package/src/tools/bash.ts +10 -3
  118. package/src/tools/eval-render.ts +13 -8
  119. package/src/tools/gh.ts +39 -1
  120. package/src/tools/image-gen.ts +114 -78
  121. package/src/tools/inspect-image.ts +1 -5
  122. package/src/tools/job.ts +25 -5
  123. package/src/tools/read.ts +1 -57
  124. package/src/tools/render-utils.ts +29 -31
  125. package/src/tools/ssh.ts +3 -3
  126. package/src/tools/todo.ts +8 -128
  127. package/src/tools/tts.ts +40 -20
  128. package/src/utils/clipboard.ts +56 -4
  129. package/src/utils/commit-message-generator.ts +1 -5
  130. package/src/utils/session-color.ts +83 -9
  131. package/src/utils/title-generator.ts +1 -1
  132. package/src/web/kagi.ts +26 -27
  133. package/src/web/search/providers/codex.ts +42 -40
  134. package/src/web/search/providers/gemini.ts +42 -22
  135. 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/todo.ts CHANGED
@@ -21,13 +21,6 @@ export type TodoStatus = "pending" | "in_progress" | "completed" | "abandoned";
21
21
  export interface TodoItem {
22
22
  content: string;
23
23
  status: TodoStatus;
24
- /**
25
- * Append-only list of freeform notes attached by `op: "note"`.
26
- * Each element is one note and may itself be multi-line.
27
- * Rendered as text only when the task is in_progress; otherwise shown as a
28
- * dim marker indicating the task has notes.
29
- */
30
- notes?: string[];
31
24
  }
32
25
 
33
26
  export interface TodoPhase {
@@ -51,7 +44,7 @@ export interface TodoToolDetails {
51
44
  // =============================================================================
52
45
 
53
46
  const TodoOp = z
54
- .enum(["init", "start", "done", "rm", "drop", "append", "note", "view"] as const)
47
+ .enum(["init", "start", "done", "rm", "drop", "append", "view"] as const)
55
48
  .describe("operation to apply");
56
49
 
57
50
  const InitListEntry = z.object({
@@ -65,7 +58,6 @@ const TodoOpEntry = z.object({
65
58
  task: z.string().optional().describe("task content"),
66
59
  phase: z.string().optional().describe("phase name"),
67
60
  items: z.array(z.string().describe("task content")).min(1).optional().describe("tasks to append"),
68
- text: z.string().optional().describe("note text"),
69
61
  });
70
62
 
71
63
  const todoSchema = z
@@ -94,9 +86,7 @@ function findPhaseByName(phases: TodoPhase[], name: string): TodoPhase | undefin
94
86
  }
95
87
 
96
88
  function cloneTask(task: TodoItem): TodoItem {
97
- const out: TodoItem = { content: task.content, status: task.status };
98
- if (task.notes && task.notes.length > 0) out.notes = [...task.notes];
99
- return out;
89
+ return { content: task.content, status: task.status };
100
90
  }
101
91
 
102
92
  function clonePhases(phases: TodoPhase[]): TodoPhase[] {
@@ -392,17 +382,6 @@ function applyEntry(phases: TodoPhase[], entry: TodoOpEntryValue, errors: string
392
382
  }
393
383
  case "rm":
394
384
  return removeTasks(phases, entry, errors);
395
- case "note": {
396
- const hit = resolveTaskOrError(phases, entry.task, errors);
397
- if (!hit) return phases;
398
- const text = (entry.text ?? "").replace(/\s+$/u, "");
399
- if (!text) {
400
- errors.push("Missing text for note operation");
401
- return phases;
402
- }
403
- hit.task.notes = hit.task.notes ? [...hit.task.notes, text] : [text];
404
- return phases;
405
- }
406
385
  case "append":
407
386
  return appendItems(phases, entry, errors);
408
387
  case "view":
@@ -448,14 +427,6 @@ export function phasesToMarkdown(phases: TodoPhase[]): string {
448
427
  out.push(`# ${phases[i].name}`);
449
428
  for (const task of phases[i].tasks) {
450
429
  out.push(`- [${STATUS_TO_MARKER[task.status]}] ${task.content}`);
451
- if (task.notes && task.notes.length > 0) {
452
- for (let j = 0; j < task.notes.length; j++) {
453
- if (j > 0) out.push(" >");
454
- for (const noteLine of task.notes[j].split("\n")) {
455
- out.push(noteLine === "" ? " >" : ` > ${noteLine}`);
456
- }
457
- }
458
- }
459
430
  }
460
431
  }
461
432
  return `${out.join("\n")}\n`;
@@ -477,45 +448,16 @@ export function markdownToPhases(md: string): { phases: TodoPhase[]; errors: str
477
448
  const errors: string[] = [];
478
449
  const phases: TodoPhase[] = [];
479
450
  let currentPhase: TodoPhase | undefined;
480
- let currentTask: TodoItem | undefined;
481
- let noteBuf: string[] = [];
482
-
483
- const flushNote = () => {
484
- if (!currentTask || noteBuf.length === 0) {
485
- noteBuf = [];
486
- return;
487
- }
488
- while (noteBuf.length > 0 && noteBuf[noteBuf.length - 1] === "") noteBuf.pop();
489
- if (noteBuf.length === 0) return;
490
- const joined = noteBuf.join("\n");
491
- currentTask.notes = currentTask.notes ? [...currentTask.notes, joined] : [joined];
492
- noteBuf = [];
493
- };
494
451
 
495
452
  const lines = md.split(/\r?\n/);
496
453
  for (let lineNum = 0; lineNum < lines.length; lineNum++) {
497
454
  const raw = lines[lineNum];
498
455
 
499
- // Blockquote line attached to the current task: ` > text` or ` >`
500
- const noteMatch = /^\s*>\s?(.*)$/.exec(raw);
501
- if (noteMatch && currentTask) {
502
- const noteLine = noteMatch[1];
503
- if (noteLine === "") {
504
- // Blank `>` separates two distinct notes
505
- flushNote();
506
- } else {
507
- noteBuf.push(noteLine);
508
- }
509
- continue;
510
- }
511
-
512
456
  const trimmed = raw.trim();
513
457
  if (!trimmed) continue;
514
458
 
515
459
  const headingMatch = /^#{1,6}\s+(.+?)\s*$/.exec(trimmed);
516
460
  if (headingMatch) {
517
- flushNote();
518
- currentTask = undefined;
519
461
  currentPhase = { name: headingMatch[1].trim(), tasks: [] };
520
462
  phases.push(currentPhase);
521
463
  continue;
@@ -523,7 +465,6 @@ export function markdownToPhases(md: string): { phases: TodoPhase[]; errors: str
523
465
 
524
466
  const taskMatch = /^[-*+]\s*\[(.?)\]\s+(.+?)\s*$/.exec(trimmed);
525
467
  if (taskMatch) {
526
- flushNote();
527
468
  if (!currentPhase) {
528
469
  currentPhase = { name: "Todos", tasks: [] };
529
470
  phases.push(currentPhase);
@@ -532,19 +473,14 @@ export function markdownToPhases(md: string): { phases: TodoPhase[]; errors: str
532
473
  const status = MARKER_TO_STATUS[marker];
533
474
  if (!status) {
534
475
  errors.push(`Line ${lineNum + 1}: unknown status marker "[${marker}]" (use [ ], [x], [/], [-])`);
535
- currentTask = undefined;
536
476
  continue;
537
477
  }
538
- currentTask = { content: taskMatch[2].trim(), status };
539
- currentPhase.tasks.push(currentTask);
478
+ currentPhase.tasks.push({ content: taskMatch[2].trim(), status });
540
479
  continue;
541
480
  }
542
481
 
543
- flushNote();
544
- currentTask = undefined;
545
482
  errors.push(`Line ${lineNum + 1}: unrecognized syntax "${trimmed}"`);
546
483
  }
547
- flushNote();
548
484
 
549
485
  normalizeInProgressTask(phases);
550
486
  return { phases, errors };
@@ -596,17 +532,7 @@ function formatSummary(phases: TodoPhase[], errors: string[], readOnly = false):
596
532
  : task.status === "abandoned"
597
533
  ? "✗"
598
534
  : "○";
599
- const noteCount = task.notes?.length ?? 0;
600
- const noteMarker = noteCount > 0 ? ` (+${noteCount} note${noteCount === 1 ? "" : "s"})` : "";
601
- lines.push(` ${sym} ${task.content}${noteMarker}`);
602
- if (task.status === "in_progress" && task.notes && task.notes.length > 0) {
603
- for (let j = 0; j < task.notes.length; j++) {
604
- if (j > 0) lines.push(" ---");
605
- for (const noteLine of task.notes[j].split("\n")) {
606
- lines.push(` ${noteLine}`);
607
- }
608
- }
609
- }
535
+ lines.push(` ${sym} ${task.content}`);
610
536
  }
611
537
  }
612
538
  return lines.join("\n");
@@ -675,27 +601,6 @@ type TodoRenderArgs = {
675
601
  }>;
676
602
  };
677
603
 
678
- const SUP_DIGITS: Record<string, string> = {
679
- "0": "\u2070",
680
- "1": "\u00b9",
681
- "2": "\u00b2",
682
- "3": "\u00b3",
683
- "4": "\u2074",
684
- "5": "\u2075",
685
- "6": "\u2076",
686
- "7": "\u2077",
687
- "8": "\u2078",
688
- "9": "\u2079",
689
- };
690
-
691
- function toSuperscript(n: number): string {
692
- return n
693
- .toString()
694
- .split("")
695
- .map(d => SUP_DIGITS[d] ?? d)
696
- .join("");
697
- }
698
-
699
604
  // =============================================================================
700
605
  // Phase numbering (display-only)
701
606
  // =============================================================================
@@ -735,11 +640,6 @@ export function formatPhaseDisplayName(name: string, oneBasedIndex: number): str
735
640
  return `${phaseRomanNumeral(oneBasedIndex)}. ${name}`;
736
641
  }
737
642
 
738
- function noteMarker(count: number, uiTheme: Theme): string {
739
- if (count <= 0) return "";
740
- return uiTheme.fg("dim", chalk.italic(` \u207a${toSuperscript(count)}`));
741
- }
742
-
743
643
  export const TODO_STRIKE_HOLD_FRAMES = 2;
744
644
  export const TODO_STRIKE_REVEAL_FRAMES = 12;
745
645
  export const TODO_STRIKE_TOTAL_FRAMES = TODO_STRIKE_HOLD_FRAMES + TODO_STRIKE_REVEAL_FRAMES;
@@ -775,7 +675,6 @@ function formatTodoLine(
775
675
  frame: number | undefined,
776
676
  ): string {
777
677
  const checkbox = uiTheme.checkbox;
778
- const marker = noteMarker(item.notes?.length ?? 0, uiTheme);
779
678
  switch (item.status) {
780
679
  case "completed": {
781
680
  const revealCount = completionKeys.has(item.content) ? strikeRevealCount(item.content, frame) : undefined;
@@ -783,33 +682,15 @@ function formatTodoLine(
783
682
  revealCount === undefined
784
683
  ? strikethroughText(item.content)
785
684
  : partialStrikethrough(item.content, revealCount);
786
- return uiTheme.fg("success", `${prefix}${checkbox.checked} ${content}`) + marker;
685
+ return uiTheme.fg("success", `${prefix}${checkbox.checked} ${content}`);
787
686
  }
788
687
  case "in_progress":
789
- return uiTheme.fg("accent", `${prefix}${checkbox.unchecked} ${item.content}`) + marker;
688
+ return uiTheme.fg("accent", `${prefix}${checkbox.unchecked} ${item.content}`);
790
689
  case "abandoned":
791
- return uiTheme.fg("error", `${prefix}${checkbox.unchecked} ${strikethroughText(item.content)}`) + marker;
690
+ return uiTheme.fg("error", `${prefix}${checkbox.unchecked} ${strikethroughText(item.content)}`);
792
691
  default:
793
- return uiTheme.fg("dim", `${prefix}${checkbox.unchecked} ${item.content}`) + marker;
794
- }
795
- }
796
-
797
- function renderNoteAttachments(phases: TodoPhase[], uiTheme: Theme, indent: string): string[] {
798
- const lines: string[] = [];
799
- for (const phase of phases) {
800
- for (const task of phase.tasks) {
801
- if (task.status !== "in_progress" || !task.notes || task.notes.length === 0) continue;
802
- lines.push("");
803
- lines.push(`${indent}${uiTheme.fg("dim", chalk.italic(`§ notes — ${task.content}`))}`);
804
- for (let j = 0; j < task.notes.length; j++) {
805
- if (j > 0) lines.push("");
806
- for (const noteLine of task.notes[j].split("\n")) {
807
- lines.push(`${indent} ${uiTheme.fg("dim", noteLine)}`);
808
- }
809
- }
810
- }
692
+ return uiTheme.fg("dim", `${prefix}${checkbox.unchecked} ${item.content}`);
811
693
  }
812
- return lines;
813
694
  }
814
695
 
815
696
  /**
@@ -966,7 +847,6 @@ export const todoToolRenderer = {
966
847
  bodyLines.push(`${indent}${line}`);
967
848
  }
968
849
  }
969
- bodyLines.push(...renderNoteAttachments(phases, uiTheme, indent));
970
850
  while (bodyLines.length > 0 && bodyLines[0].trim() === "") bodyLines.shift();
971
851
  return {
972
852
  header,