@oh-my-pi/pi-coding-agent 8.2.1 → 8.3.0

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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,24 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [8.3.0] - 2026-01-25
6
+
7
+ ### Changed
8
+ - Added request parameter tracking to LSP tool rendering for better diagnostics visibility
9
+ - Added async diff computation and Kitty protocol support to tool execution rendering
10
+ - Refactored patch applicator with improved fuzzy matching (7-pass sequence matching with Levenshtein distance) and indentation adjustment
11
+ - Added inline rendering flag to bash and fetch tool renderers
12
+ - Extracted constants for preview formatting to improve code maintainability
13
+ - Exposed mergeCallAndResult and inline rendering options from tools to their wrappers
14
+ - Added timeout validation and normalization for tool timeout parameters
15
+
16
+ ### Fixed
17
+ - Fixed output block border rendering (bottom-right corner was missing)
18
+ - Added background control parameter to output block rendering
19
+ ## [8.2.2] - 2026-01-24
20
+
21
+ ### Removed
22
+ - Removed git utility functions (_git, git_status, git_diff, git_log, git_show, git_file_at, git_branch, git_has_changes) from IPython prelude
5
23
  ## [8.2.0] - 2026-01-24
6
24
 
7
25
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-coding-agent",
3
- "version": "8.2.1",
3
+ "version": "8.3.0",
4
4
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
5
5
  "type": "module",
6
6
  "ompConfig": {
@@ -75,11 +75,11 @@
75
75
  "test": "bun test"
76
76
  },
77
77
  "dependencies": {
78
- "@oh-my-pi/omp-stats": "8.2.1",
79
- "@oh-my-pi/pi-agent-core": "8.2.1",
80
- "@oh-my-pi/pi-ai": "8.2.1",
81
- "@oh-my-pi/pi-tui": "8.2.1",
82
- "@oh-my-pi/pi-utils": "8.2.1",
78
+ "@oh-my-pi/omp-stats": "8.3.0",
79
+ "@oh-my-pi/pi-agent-core": "8.3.0",
80
+ "@oh-my-pi/pi-ai": "8.3.0",
81
+ "@oh-my-pi/pi-tui": "8.3.0",
82
+ "@oh-my-pi/pi-utils": "8.3.0",
83
83
  "@openai/agents": "^0.4.3",
84
84
  "@sinclair/typebox": "^0.34.46",
85
85
  "ajv": "^8.17.1",
@@ -79,6 +79,8 @@ export class ExtensionToolWrapper<TParameters extends TSchema = TSchema, TDetail
79
79
  parameters: TParameters;
80
80
  renderCall?: AgentTool<TParameters, TDetails>["renderCall"];
81
81
  renderResult?: AgentTool<TParameters, TDetails>["renderResult"];
82
+ mergeCallAndResult?: boolean;
83
+ inline?: boolean;
82
84
 
83
85
  constructor(
84
86
  private tool: AgentTool<TParameters, TDetails>,
@@ -90,6 +92,8 @@ export class ExtensionToolWrapper<TParameters extends TSchema = TSchema, TDetail
90
92
  this.parameters = tool.parameters;
91
93
  this.renderCall = tool.renderCall;
92
94
  this.renderResult = tool.renderResult;
95
+ this.mergeCallAndResult = (tool as { mergeCallAndResult?: boolean }).mergeCallAndResult;
96
+ this.inline = (tool as { inline?: boolean }).inline;
93
97
  }
94
98
 
95
99
  async execute(
@@ -23,6 +23,8 @@ export class HookToolWrapper<TParameters extends TSchema = TSchema, TDetails = u
23
23
  parameters: TParameters;
24
24
  renderCall?: AgentTool<TParameters, TDetails>["renderCall"];
25
25
  renderResult?: AgentTool<TParameters, TDetails>["renderResult"];
26
+ mergeCallAndResult?: boolean;
27
+ inline?: boolean;
26
28
 
27
29
  constructor(
28
30
  private tool: AgentTool<TParameters, TDetails>,
@@ -34,6 +36,8 @@ export class HookToolWrapper<TParameters extends TSchema = TSchema, TDetails = u
34
36
  this.parameters = tool.parameters;
35
37
  this.renderCall = tool.renderCall;
36
38
  this.renderResult = tool.renderResult;
39
+ this.mergeCallAndResult = (tool as { mergeCallAndResult?: boolean }).mergeCallAndResult;
40
+ this.inline = (tool as { inline?: boolean }).inline;
37
41
  }
38
42
 
39
43
  async execute(
@@ -724,188 +724,6 @@ if "__omp_prelude_loaded__" not in globals():
724
724
  _emit_status("insert_at", path=str(p), line=line_num, lines_inserted=len(new_lines), position=pos)
725
725
  return p
726
726
 
727
- def _git(*args: str, cwd: str | Path | None = None) -> tuple[int, str, str]:
728
- """Run git command, return (returncode, stdout, stderr)."""
729
- result = subprocess.run(
730
- ["git", *args],
731
- cwd=str(cwd) if cwd else None,
732
- capture_output=True,
733
- text=True,
734
- )
735
- return result.returncode, result.stdout, result.stderr
736
-
737
- @_category("Git")
738
- def git_status(*, cwd: str | Path | None = None) -> dict:
739
- """Get structured git status: {branch, staged, modified, untracked, ahead, behind}."""
740
- code, out, err = _git("status", "--porcelain=v2", "--branch", cwd=cwd)
741
- if code != 0:
742
- _emit_status("git_status", error=err.strip())
743
- return {}
744
-
745
- result: dict = {"branch": None, "staged": [], "modified": [], "untracked": [], "ahead": 0, "behind": 0}
746
- for line in out.splitlines():
747
- if line.startswith("# branch.head "):
748
- result["branch"] = line.split(" ", 2)[2]
749
- elif line.startswith("# branch.ab "):
750
- parts = line.split()
751
- for p in parts[2:]:
752
- if p.startswith("+"):
753
- result["ahead"] = int(p[1:])
754
- elif p.startswith("-"):
755
- result["behind"] = int(p[1:])
756
- elif line.startswith("1 ") or line.startswith("2 "):
757
- parts = line.split(" ", 8)
758
- xy = parts[1]
759
- path = parts[-1]
760
- if xy[0] != ".":
761
- result["staged"].append(path)
762
- if xy[1] != ".":
763
- result["modified"].append(path)
764
- elif line.startswith("? "):
765
- result["untracked"].append(line[2:])
766
-
767
- clean = not any([result["staged"], result["modified"], result["untracked"]])
768
- _emit_status("git_status", branch=result["branch"], staged=len(result["staged"]), modified=len(result["modified"]), untracked=len(result["untracked"]), clean=clean, files=result["staged"][:5] + result["modified"][:5])
769
- return result
770
-
771
- @_category("Git")
772
- def git_diff(
773
- *paths: str,
774
- staged: bool = False,
775
- ref: str | None = None,
776
- stat: bool = False,
777
- cwd: str | Path | None = None,
778
- ) -> str:
779
- """Show git diff. staged=True for --cached, ref for commit comparison."""
780
- args = ["diff"]
781
- if stat:
782
- args.append("--stat")
783
- if staged:
784
- args.append("--cached")
785
- if ref:
786
- args.append(ref)
787
- if paths:
788
- args.append("--")
789
- args.extend(paths)
790
- code, out, err = _git(*args, cwd=cwd)
791
- if code != 0:
792
- _emit_status("git_diff", error=err.strip())
793
- return ""
794
- lines_count = len(out.splitlines()) if out else 0
795
- _emit_status("git_diff", staged=staged, ref=ref, lines=lines_count, preview=out[:500])
796
- return out
797
-
798
- @_category("Git")
799
- def git_log(
800
- n: int = 10,
801
- *,
802
- oneline: bool = True,
803
- ref_range: str | None = None,
804
- paths: list[str] | None = None,
805
- cwd: str | Path | None = None,
806
- ) -> list[dict]:
807
- """Get git log as list of {sha, subject, author, date}."""
808
- fmt = "%H%x00%s%x00%an%x00%aI" if not oneline else "%h%x00%s%x00%an%x00%aI"
809
- args = ["log", f"-{n}", f"--format={fmt}"]
810
- if ref_range:
811
- args.append(ref_range)
812
- if paths:
813
- args.append("--")
814
- args.extend(paths)
815
- code, out, err = _git(*args, cwd=cwd)
816
- if code != 0:
817
- _emit_status("git_log", error=err.strip())
818
- return []
819
-
820
- commits = []
821
- for line in out.strip().splitlines():
822
- parts = line.split("\x00")
823
- if len(parts) >= 4:
824
- commits.append({"sha": parts[0], "subject": parts[1], "author": parts[2], "date": parts[3]})
825
-
826
- _emit_status("git_log", commits=len(commits), entries=[{"sha": c["sha"][:8], "subject": c["subject"][:50]} for c in commits[:5]])
827
- return commits
828
-
829
- @_category("Git")
830
- def git_show(ref: str = "HEAD", *, stat: bool = True, cwd: str | Path | None = None) -> dict:
831
- """Show commit details as {sha, subject, author, date, body, files}."""
832
- args = ["show", ref, "--format=%H%x00%s%x00%an%x00%aI%x00%b", "--no-patch"]
833
- code, out, err = _git(*args, cwd=cwd)
834
- if code != 0:
835
- _emit_status("git_show", ref=ref, error=err.strip())
836
- return {}
837
-
838
- parts = out.strip().split("\x00")
839
- result = {
840
- "sha": parts[0] if len(parts) > 0 else "",
841
- "subject": parts[1] if len(parts) > 1 else "",
842
- "author": parts[2] if len(parts) > 2 else "",
843
- "date": parts[3] if len(parts) > 3 else "",
844
- "body": parts[4].strip() if len(parts) > 4 else "",
845
- "files": [],
846
- }
847
-
848
- if stat:
849
- _, stat_out, _ = _git("show", ref, "--stat", "--format=", cwd=cwd)
850
- result["files"] = [l.strip() for l in stat_out.strip().splitlines() if l.strip()]
851
-
852
- _emit_status("git_show", ref=ref, sha=result["sha"][:12], subject=result["subject"][:60], files=len(result["files"]))
853
- return result
854
-
855
- @_category("Git")
856
- def git_file_at(ref: str, path: str, *, lines: tuple[int, int] | None = None, cwd: str | Path | None = None) -> str:
857
- """Get file content at ref. Optional lines=(start, end) for range (1-indexed)."""
858
- code, out, err = _git("show", f"{ref}:{path}", cwd=cwd)
859
- if code != 0:
860
- _emit_status("git_file_at", ref=ref, path=path, error=err.strip())
861
- return ""
862
-
863
- if lines:
864
- all_lines = out.splitlines()
865
- start, end = lines
866
- start = max(1, start)
867
- end = min(len(all_lines), end)
868
- selected = all_lines[start - 1 : end]
869
- out = "\n".join(selected)
870
- _emit_status("git_file_at", ref=ref, path=path, start=start, end=end, lines=len(selected))
871
- return out
872
-
873
- _emit_status("git_file_at", ref=ref, path=path, chars=len(out))
874
- return out
875
-
876
- @_category("Git")
877
- def git_branch(*, cwd: str | Path | None = None) -> dict:
878
- """Get branches: {current, local, remote}."""
879
- code, out, _ = _git("branch", "-a", "--format=%(refname:short)%00%(HEAD)", cwd=cwd)
880
- if code != 0:
881
- _emit_status("git_branch", error="failed to list branches")
882
- return {"current": None, "local": [], "remote": []}
883
-
884
- result: dict = {"current": None, "local": [], "remote": []}
885
- for line in out.strip().splitlines():
886
- parts = line.split("\x00")
887
- name = parts[0]
888
- is_current = len(parts) > 1 and parts[1] == "*"
889
- if is_current:
890
- result["current"] = name
891
- if name.startswith("remotes/") or "/" in name and not name.startswith("feature/"):
892
- result["remote"].append(name)
893
- else:
894
- result["local"].append(name)
895
- if is_current:
896
- result["current"] = name
897
-
898
- _emit_status("git_branch", current=result["current"], local=len(result["local"]), remote=len(result["remote"]), branches=result["local"][:10])
899
- return result
900
-
901
- @_category("Git")
902
- def git_has_changes(*, cwd: str | Path | None = None) -> bool:
903
- """Check if there are uncommitted changes (staged or unstaged)."""
904
- code, out, _ = _git("status", "--porcelain", cwd=cwd)
905
- has_changes = bool(out.strip())
906
- _emit_status("git_has_changes", has_changes=has_changes)
907
- return has_changes
908
-
909
727
  @_category("Agent")
910
728
  def output(
911
729
  *ids: str,
package/src/lsp/index.ts CHANGED
@@ -955,6 +955,8 @@ export class LspTool implements AgentTool<typeof lspSchema, LspToolDetails, Them
955
955
  public readonly parameters = lspSchema;
956
956
  public readonly renderCall = renderCall;
957
957
  public readonly renderResult = renderResult;
958
+ public readonly mergeCallAndResult = true;
959
+ public readonly inline = true;
958
960
 
959
961
  private readonly session: ToolSession;
960
962
 
@@ -1011,7 +1013,7 @@ export class LspTool implements AgentTool<typeof lspSchema, LspToolDetails, Them
1011
1013
  const output = lspmuxStatus ? `${serverStatus}\n${lspmuxStatus}` : serverStatus;
1012
1014
  return {
1013
1015
  content: [{ type: "text", text: output }],
1014
- details: { action, success: true },
1016
+ details: { action, success: true, request: params },
1015
1017
  };
1016
1018
  }
1017
1019
 
@@ -1025,7 +1027,7 @@ export class LspTool implements AgentTool<typeof lspSchema, LspToolDetails, Them
1025
1027
  text: `Workspace diagnostics (${result.projectType.description}):\n${result.output}`,
1026
1028
  },
1027
1029
  ],
1028
- details: { action, success: true },
1030
+ details: { action, success: true, request: params },
1029
1031
  };
1030
1032
  }
1031
1033
 
@@ -1636,13 +1638,13 @@ export class LspTool implements AgentTool<typeof lspSchema, LspToolDetails, Them
1636
1638
 
1637
1639
  return {
1638
1640
  content: [{ type: "text", text: output }],
1639
- details: { serverName, action, success: true },
1641
+ details: { serverName, action, success: true, request: params },
1640
1642
  };
1641
1643
  } catch (err) {
1642
1644
  const errorMessage = err instanceof Error ? err.message : String(err);
1643
1645
  return {
1644
1646
  content: [{ type: "text", text: `LSP error: ${errorMessage}` }],
1645
- details: { serverName, action, success: false },
1647
+ details: { serverName, action, success: false, request: params },
1646
1648
  };
1647
1649
  }
1648
1650
  }
package/src/lsp/render.ts CHANGED
@@ -7,11 +7,18 @@
7
7
  * - Grouped references and symbols
8
8
  * - Collapsible/expandable views
9
9
  */
10
- import type { AgentToolResult, RenderResultOptions } from "@oh-my-pi/pi-agent-core";
10
+ import type { RenderResultOptions } from "@oh-my-pi/pi-agent-core";
11
11
  import { type Component, Text } from "@oh-my-pi/pi-tui";
12
12
  import { highlight, supportsLanguage } from "cli-highlight";
13
13
  import { getLanguageFromPath, type Theme } from "../modes/theme/theme";
14
- import { formatExpandHint, formatMoreItems, TRUNCATE_LENGTHS, truncate } from "../tools/render-utils";
14
+ import {
15
+ formatExpandHint,
16
+ formatMoreItems,
17
+ formatStatusIcon,
18
+ shortenPath,
19
+ TRUNCATE_LENGTHS,
20
+ truncate,
21
+ } from "../tools/render-utils";
15
22
  import { renderOutputBlock, renderStatusLine } from "../tui";
16
23
  import type { LspParams, LspToolDetails } from "./types";
17
24
 
@@ -23,15 +30,74 @@ import type { LspParams, LspToolDetails } from "./types";
23
30
  * Render the LSP tool call in the TUI.
24
31
  * Shows: "lsp <operation> <file/filecount>"
25
32
  */
26
- export function renderCall(args: unknown, theme: Theme): Text {
27
- const p = args as LspParams & { file?: string; files?: string[] };
33
+ export function renderCall(args: LspParams, theme: Theme): Text {
34
+ const actionLabel = (args.action ?? "request").replace(/_/g, " ");
35
+ const queryPreview = args.query ? truncate(args.query, TRUNCATE_LENGTHS.SHORT, theme.format.ellipsis) : undefined;
36
+ const replacementPreview = args.replacement
37
+ ? truncate(args.replacement, TRUNCATE_LENGTHS.SHORT, theme.format.ellipsis)
38
+ : undefined;
39
+
40
+ let target: string | undefined;
41
+ let hasFileTarget = false;
42
+
43
+ if (args.file) {
44
+ target = shortenPath(args.file);
45
+ hasFileTarget = true;
46
+ } else if (args.files?.length === 1) {
47
+ target = shortenPath(args.files[0]);
48
+ hasFileTarget = true;
49
+ } else if (args.files?.length) {
50
+ target = `${args.files.length} files`;
51
+ }
52
+
53
+ if (hasFileTarget && args.line !== undefined) {
54
+ const col = args.column !== undefined ? `:${args.column}` : "";
55
+ target += `:${args.line}${col}`;
56
+ if (args.end_line !== undefined) {
57
+ const endCol = args.end_character !== undefined ? `:${args.end_character}` : "";
58
+ target += `-${args.end_line}${endCol}`;
59
+ }
60
+ } else if (!target && args.line !== undefined) {
61
+ const col = args.column !== undefined ? `:${args.column}` : "";
62
+ target = `line ${args.line}${col}`;
63
+ if (args.end_line !== undefined) {
64
+ const endCol = args.end_character !== undefined ? `:${args.end_character}` : "";
65
+ target += `-${args.end_line}${endCol}`;
66
+ }
67
+ }
68
+
28
69
  const meta: string[] = [];
29
- if (p.file) {
30
- meta.push(p.file);
31
- } else if (p.files?.length) {
32
- meta.push(`${p.files.length} file(s)`);
70
+ if (queryPreview && target) meta.push(`query:${queryPreview}`);
71
+ if (args.new_name) meta.push(`new:${args.new_name}`);
72
+ if (replacementPreview) meta.push(`replace:${replacementPreview}`);
73
+ if (args.kind) meta.push(`kind:${args.kind}`);
74
+ if (args.apply !== undefined) meta.push(`apply:${args.apply ? "true" : "false"}`);
75
+ if (args.action_index !== undefined) meta.push(`action:${args.action_index}`);
76
+ if (args.include_declaration !== undefined) {
77
+ meta.push(`include_decl:${args.include_declaration ? "true" : "false"}`);
78
+ }
79
+ if (args.end_line !== undefined && args.line === undefined) {
80
+ const endCol = args.end_character !== undefined ? `:${args.end_character}` : "";
81
+ meta.push(`end:${args.end_line}${endCol}`);
82
+ }
83
+
84
+ const descriptionParts = [actionLabel];
85
+ if (target) {
86
+ descriptionParts.push(target);
87
+ } else if (queryPreview) {
88
+ descriptionParts.push(queryPreview);
33
89
  }
34
- const text = renderStatusLine({ icon: "pending", title: "LSP", description: p.action || "?", meta }, theme);
90
+
91
+ const text = renderStatusLine(
92
+ {
93
+ icon: "pending",
94
+ title: "LSP",
95
+ description: descriptionParts.join(" "),
96
+ meta,
97
+ },
98
+ theme,
99
+ );
100
+
35
101
  return new Text(text, 0, 0);
36
102
  }
37
103
 
@@ -44,14 +110,15 @@ export function renderCall(args: unknown, theme: Theme): Text {
44
110
  * Detects hover, diagnostics, references, symbols, etc. and formats accordingly.
45
111
  */
46
112
  export function renderResult(
47
- result: AgentToolResult<LspToolDetails>,
113
+ result: { content: Array<{ type: string; text?: string }>; details?: LspToolDetails; isError?: boolean },
48
114
  options: RenderResultOptions,
49
115
  theme: Theme,
50
116
  args?: LspParams & { file?: string; files?: string[] },
51
117
  ): Component {
52
118
  const content = result.content?.[0];
53
119
  if (!content || content.type !== "text" || !("text" in content) || !content.text) {
54
- const header = renderStatusLine({ icon: "warning", title: "LSP", description: "No result" }, theme);
120
+ const icon = formatStatusIcon("warning", theme, options.spinnerFrame);
121
+ const header = `${icon} LSP`;
55
122
  return new Text([header, theme.fg("dim", "No result")].join("\n"), 0, 0);
56
123
  }
57
124
 
@@ -94,22 +161,50 @@ export function renderResult(
94
161
  }
95
162
  }
96
163
 
97
- const meta: string[] = [];
98
- if (args?.action) meta.push(args.action);
99
- if (args?.file) {
100
- meta.push(args.file);
101
- } else if (args?.files?.length) {
102
- meta.push(`${args.files.length} file(s)`);
164
+ const request = args ?? result.details?.request;
165
+ const requestLines: string[] = [];
166
+ if (request?.file) {
167
+ requestLines.push(theme.fg("toolOutput", request.file));
168
+ } else if (request?.files?.length === 1) {
169
+ requestLines.push(theme.fg("toolOutput", request.files[0]));
170
+ } else if (request?.files?.length) {
171
+ requestLines.push(theme.fg("dim", `${request.files.length} file(s)`));
172
+ }
173
+ if (request?.line !== undefined) {
174
+ const col = request.column !== undefined ? `:${request.column}` : "";
175
+ requestLines.push(theme.fg("dim", `line ${request.line}${col}`));
103
176
  }
104
- const header = renderStatusLine({ icon: state, title: "LSP", description: label, meta }, theme);
177
+ if (request?.end_line !== undefined) {
178
+ const endCol = request.end_character !== undefined ? `:${request.end_character}` : "";
179
+ requestLines.push(theme.fg("dim", `end ${request.end_line}${endCol}`));
180
+ }
181
+ if (request?.query) requestLines.push(theme.fg("dim", `query: ${request.query}`));
182
+ if (request?.new_name) requestLines.push(theme.fg("dim", `new name: ${request.new_name}`));
183
+ if (request?.replacement) requestLines.push(theme.fg("dim", `replacement: ${request.replacement}`));
184
+ if (request?.kind) requestLines.push(theme.fg("dim", `kind: ${request.kind}`));
185
+ if (request?.apply !== undefined) requestLines.push(theme.fg("dim", `apply: ${request.apply ? "true" : "false"}`));
186
+ if (request?.action_index !== undefined) requestLines.push(theme.fg("dim", `action: ${request.action_index}`));
187
+ if (request?.include_declaration !== undefined) {
188
+ requestLines.push(theme.fg("dim", `include declaration: ${request.include_declaration ? "true" : "false"}`));
189
+ }
190
+
191
+ const actionLabel = (request?.action ?? result.details?.action ?? label.toLowerCase()).replace(/_/g, " ");
192
+ const status = options.isPartial ? "running" : result.isError ? "error" : "success";
193
+ const icon = formatStatusIcon(status, theme, options.spinnerFrame);
194
+ const header = `${icon} LSP ${actionLabel}`;
195
+
105
196
  return {
106
197
  render: (width: number) =>
107
198
  renderOutputBlock(
108
199
  {
109
200
  header,
110
201
  state,
111
- sections: [{ label: theme.fg("toolTitle", label), lines: bodyLines }],
202
+ sections: [
203
+ ...(requestLines.length > 0 ? [{ lines: requestLines }] : []),
204
+ { label: theme.fg("toolTitle", "Response"), lines: bodyLines },
205
+ ],
112
206
  width,
207
+ applyBg: false,
113
208
  },
114
209
  theme,
115
210
  ),
@@ -612,4 +707,5 @@ export const lspToolRenderer = {
612
707
  renderCall,
613
708
  renderResult,
614
709
  mergeCallAndResult: true,
710
+ inline: true,
615
711
  };
package/src/lsp/types.ts CHANGED
@@ -53,6 +53,7 @@ export interface LspToolDetails {
53
53
  serverName?: string;
54
54
  action: string;
55
55
  success: boolean;
56
+ request?: LspParams;
56
57
  }
57
58
 
58
59
  // =============================================================================
@@ -1,6 +1,7 @@
1
1
  import type { AgentTool } from "@oh-my-pi/pi-agent-core";
2
2
  import {
3
3
  Box,
4
+ type Component,
4
5
  Container,
5
6
  getCapabilities,
6
7
  getImageDimensions,
@@ -11,6 +12,7 @@ import {
11
12
  type TUI,
12
13
  } from "@oh-my-pi/pi-tui";
13
14
  import { sanitizeText } from "@oh-my-pi/pi-utils";
15
+ import type { Theme } from "../../modes/theme/theme";
14
16
  import { theme } from "../../modes/theme/theme";
15
17
  import { computeEditDiff, computePatchDiff, type EditDiffError, type EditDiffResult } from "../../patch";
16
18
  import { BASH_DEFAULT_PREVIEW_LINES } from "../../tools/bash";
@@ -376,7 +378,8 @@ export class ToolExecutionComponent extends Container {
376
378
  const tool = this.tool;
377
379
  const mergeCallAndResult = Boolean((tool as { mergeCallAndResult?: boolean }).mergeCallAndResult);
378
380
  // Custom tools use Box for flexible component rendering
379
- this.contentBox.setBgFn(bgFn);
381
+ const inline = Boolean((tool as { inline?: boolean }).inline);
382
+ this.contentBox.setBgFn(inline ? undefined : bgFn);
380
383
  this.contentBox.clear();
381
384
 
382
385
  // Render call component
@@ -404,10 +407,17 @@ export class ToolExecutionComponent extends Container {
404
407
  // Render result component if we have a result
405
408
  if (this.result && tool.renderResult) {
406
409
  try {
407
- const resultComponent = tool.renderResult(
408
- { content: this.result.content as any, details: this.result.details },
410
+ const renderResult = tool.renderResult as (
411
+ result: { content: Array<{ type: string; text?: string }>; details?: unknown; isError?: boolean },
412
+ options: { expanded: boolean; isPartial: boolean; spinnerFrame?: number },
413
+ theme: Theme,
414
+ args?: unknown,
415
+ ) => Component;
416
+ const resultComponent = renderResult(
417
+ { content: this.result.content as any, details: this.result.details, isError: this.result.isError },
409
418
  { expanded: this.expanded, isPartial: this.isPartial, spinnerFrame: this.spinnerFrame },
410
419
  theme,
420
+ this.args,
411
421
  );
412
422
  if (resultComponent) {
413
423
  // Ensure component has invalidate() method for Component interface
@@ -542,10 +552,16 @@ export class ToolExecutionComponent extends Container {
542
552
  }
543
553
 
544
554
  /**
545
- * Build render context for tools that need extra state (bash, edit)
555
+ * Build render context for tools that need extra state (bash, python, edit)
546
556
  */
547
557
  private buildRenderContext(): Record<string, unknown> {
548
558
  const context: Record<string, unknown> = {};
559
+ const normalizeTimeoutSeconds = (value: unknown, maxSeconds: number): number | undefined => {
560
+ if (typeof value !== "number" || !Number.isFinite(value)) return undefined;
561
+ let timeoutSec = value > 1000 ? value / 1000 : value;
562
+ timeoutSec = Math.max(1, Math.min(maxSeconds, timeoutSec));
563
+ return timeoutSec;
564
+ };
549
565
 
550
566
  if (this.toolName === "bash" && this.result) {
551
567
  // Pass raw output and expanded state - renderer handles width-aware truncation
@@ -553,13 +569,13 @@ export class ToolExecutionComponent extends Container {
553
569
  context.output = output;
554
570
  context.expanded = this.expanded;
555
571
  context.previewLines = BASH_DEFAULT_PREVIEW_LINES;
556
- context.timeout = typeof this.args?.timeout === "number" ? this.args.timeout : undefined;
572
+ context.timeout = normalizeTimeoutSeconds(this.args?.timeout, 3600);
557
573
  } else if (this.toolName === "python" && this.result) {
558
574
  const output = this.getTextOutput().trimEnd();
559
575
  context.output = output;
560
576
  context.expanded = this.expanded;
561
577
  context.previewLines = PYTHON_DEFAULT_PREVIEW_LINES;
562
- context.timeout = typeof this.args?.timeout === "number" ? this.args.timeout : undefined;
578
+ context.timeout = normalizeTimeoutSeconds(this.args?.timeout, 600);
563
579
  } else if (this.toolName === "edit") {
564
580
  // Edit needs diff preview and renderDiff function
565
581
  context.editDiffPreview = this.editDiffPreview;
@@ -957,11 +957,15 @@ function computeReplacements(
957
957
  if (secondMatch.index !== undefined) {
958
958
  // Extract 3-line previews for each match
959
959
  const formatPreview = (startIdx: number) => {
960
- const lines = originalLines.slice(startIdx, startIdx + 3);
960
+ const contextLines = 2;
961
+ const maxLineLength = 80;
962
+ const start = Math.max(0, startIdx - contextLines);
963
+ const end = Math.min(originalLines.length, startIdx + contextLines + 1);
964
+ const lines = originalLines.slice(start, end);
961
965
  return lines
962
966
  .map((line, i) => {
963
- const num = startIdx + i + 1;
964
- const truncated = line.length > 60 ? `${line.slice(0, 57)}...` : line;
967
+ const num = start + i + 1;
968
+ const truncated = line.length > maxLineLength ? `${line.slice(0, maxLineLength - 3)}...` : line;
965
969
  return ` ${num} | ${truncated}`;
966
970
  })
967
971
  .join("\n");
@@ -29,6 +29,12 @@ const PARTIAL_MATCH_MIN_LENGTH = 6;
29
29
  /** Minimum ratio of pattern to line length for substring match */
30
30
  const PARTIAL_MATCH_MIN_RATIO = 0.3;
31
31
 
32
+ /** Context lines to show before/after an ambiguous match preview */
33
+ const OCCURRENCE_PREVIEW_CONTEXT = 5;
34
+
35
+ /** Maximum line length for ambiguous match previews */
36
+ const OCCURRENCE_PREVIEW_MAX_LEN = 80;
37
+
32
38
  // ═══════════════════════════════════════════════════════════════════════════
33
39
  // Core Algorithms
34
40
  // ═══════════════════════════════════════════════════════════════════════════
@@ -224,10 +230,14 @@ export function findMatch(
224
230
  if (idx === -1) break;
225
231
  const lineNumber = content.slice(0, idx).split("\n").length;
226
232
  occurrenceLines.push(lineNumber);
227
- // Extract 3 lines starting from match (0-indexed)
228
- const previewLines = contentLines.slice(lineNumber - 1, lineNumber + 2);
233
+ const start = Math.max(0, lineNumber - 1 - OCCURRENCE_PREVIEW_CONTEXT);
234
+ const end = Math.min(contentLines.length, lineNumber + OCCURRENCE_PREVIEW_CONTEXT + 1);
235
+ const previewLines = contentLines.slice(start, end);
229
236
  const preview = previewLines
230
- .map((line, i) => ` ${lineNumber + i} | ${line.length > 60 ? `${line.slice(0, 57)}...` : line}`)
237
+ .map((line, idx) => {
238
+ const num = start + idx + 1;
239
+ return ` ${num} | ${line.length > OCCURRENCE_PREVIEW_MAX_LEN ? `${line.slice(0, OCCURRENCE_PREVIEW_MAX_LEN - 3)}...` : line}`;
240
+ })
231
241
  .join("\n");
232
242
  occurrencePreviews.push(preview);
233
243
  searchStart = idx + 1;
package/src/tools/bash.ts CHANGED
@@ -288,4 +288,5 @@ export const bashToolRenderer = {
288
288
  };
289
289
  },
290
290
  mergeCallAndResult: true,
291
+ inline: true,
291
292
  };
@@ -1082,6 +1082,7 @@ export function renderFetchResult(
1082
1082
  { label: uiTheme.fg("toolTitle", "Content Preview"), lines: contentPreviewLines },
1083
1083
  ],
1084
1084
  width,
1085
+ applyBg: false,
1085
1086
  },
1086
1087
  uiTheme,
1087
1088
  ),
@@ -15,10 +15,17 @@ import { createLspWritethrough, type FileDiagnosticsResult, type WritethroughCal
15
15
  import { getLanguageFromPath, type Theme } from "../modes/theme/theme";
16
16
  import writeDescription from "../prompts/tools/write.md" with { type: "text" };
17
17
  import type { ToolSession } from "../sdk";
18
- import { renderCodeCell, renderStatusLine } from "../tui";
18
+ import { renderStatusLine } from "../tui";
19
19
  import { type OutputMeta, outputMeta } from "./output-meta";
20
20
  import { resolveToCwd } from "./path-utils";
21
- import { formatDiagnostics, shortenPath } from "./render-utils";
21
+ import {
22
+ formatDiagnostics,
23
+ formatExpandHint,
24
+ formatMoreItems,
25
+ formatStatusIcon,
26
+ shortenPath,
27
+ ToolUIKit,
28
+ } from "./render-utils";
22
29
  import type { RenderCallOptions } from "./renderers";
23
30
 
24
31
  const writeSchema = Type.Object({
@@ -123,6 +130,7 @@ interface WriteRenderArgs {
123
130
  content?: string;
124
131
  }
125
132
 
133
+ const WRITE_PREVIEW_LINES = 6;
126
134
  const WRITE_STREAMING_PREVIEW_LINES = 12;
127
135
 
128
136
  function countLines(text: string): number {
@@ -138,93 +146,98 @@ function formatMetadataLine(lineCount: number | null, language: string | undefin
138
146
  return uiTheme.fg("dim", `${icon}`);
139
147
  }
140
148
 
149
+ function formatStreamingContent(content: string, uiTheme: Theme, ui: ToolUIKit): string {
150
+ if (!content) return "";
151
+ const lines = content.split("\n");
152
+ const displayLines = lines.slice(-WRITE_STREAMING_PREVIEW_LINES);
153
+ const hidden = lines.length - displayLines.length;
154
+
155
+ let text = "\n\n";
156
+ if (hidden > 0) {
157
+ text += uiTheme.fg("dim", `${uiTheme.format.ellipsis} (${hidden} earlier lines)\n`);
158
+ }
159
+ for (const line of displayLines) {
160
+ text += `${uiTheme.fg("toolOutput", ui.truncate(line, 80))}\n`;
161
+ }
162
+ text += uiTheme.fg("dim", `${uiTheme.format.ellipsis} (streaming)`);
163
+ return text;
164
+ }
165
+
166
+ function renderContentPreview(content: string, expanded: boolean, uiTheme: Theme, ui: ToolUIKit): string {
167
+ if (!content) return "";
168
+ const lines = content.split("\n");
169
+ const maxLines = expanded ? lines.length : Math.min(lines.length, WRITE_PREVIEW_LINES);
170
+ const displayLines = expanded ? lines : lines.slice(-maxLines);
171
+ const hidden = lines.length - displayLines.length;
172
+
173
+ let text = "\n\n";
174
+ for (const line of displayLines) {
175
+ text += `${uiTheme.fg("toolOutput", ui.truncate(line, 80))}\n`;
176
+ }
177
+ if (!expanded && hidden > 0) {
178
+ const hint = formatExpandHint(uiTheme, expanded, hidden > 0);
179
+ const moreLine = `${formatMoreItems(hidden, "line", uiTheme)}${hint ? ` ${hint}` : ""}`;
180
+ text += uiTheme.fg("dim", moreLine);
181
+ }
182
+ return text;
183
+ }
184
+
141
185
  export const writeToolRenderer = {
142
186
  renderCall(args: WriteRenderArgs, uiTheme: Theme, options?: RenderCallOptions): Component {
187
+ const ui = new ToolUIKit(uiTheme);
143
188
  const rawPath = args.file_path || args.path || "";
144
189
  const filePath = shortenPath(rawPath);
145
- const pathDisplay = filePath || uiTheme.format.ellipsis;
146
- const status = options?.spinnerFrame !== undefined ? "running" : "pending";
147
- const text = renderStatusLine(
148
- { icon: status, title: "Write", description: pathDisplay, spinnerFrame: options?.spinnerFrame },
149
- uiTheme,
150
- );
190
+ const lang = getLanguageFromPath(rawPath) ?? "text";
191
+ const langIcon = uiTheme.fg("muted", uiTheme.getLangIcon(lang));
192
+ const pathDisplay = filePath ? uiTheme.fg("accent", filePath) : uiTheme.fg("toolOutput", uiTheme.format.ellipsis);
193
+ const spinner =
194
+ options?.spinnerFrame !== undefined ? formatStatusIcon("running", uiTheme, options.spinnerFrame) : "";
195
+
196
+ let text = `${ui.title("Write")} ${spinner ? `${spinner} ` : ""}${langIcon} ${pathDisplay}`;
197
+
151
198
  if (!args.content) {
152
199
  return new Text(text, 0, 0);
153
200
  }
154
201
 
155
- const contentLines = args.content.split("\n");
156
- const displayLines = contentLines.slice(-WRITE_STREAMING_PREVIEW_LINES);
157
- const hidden = contentLines.length - displayLines.length;
158
- const outputLines: string[] = [];
159
- if (hidden > 0) {
160
- outputLines.push(uiTheme.fg("dim", `${uiTheme.format.ellipsis} (${hidden} earlier lines)`));
161
- }
162
- outputLines.push(uiTheme.fg("dim", `${uiTheme.format.ellipsis} (streaming)`));
163
-
164
- return {
165
- render: (width: number) =>
166
- renderCodeCell(
167
- {
168
- code: displayLines.join("\n"),
169
- language: getLanguageFromPath(rawPath),
170
- title: filePath ? `Write ${filePath}` : "Write",
171
- status,
172
- spinnerFrame: options?.spinnerFrame,
173
- output: outputLines.join("\n"),
174
- codeMaxLines: WRITE_STREAMING_PREVIEW_LINES,
175
- expanded: true,
176
- width,
177
- },
178
- uiTheme,
179
- ),
180
- invalidate: () => {},
181
- };
202
+ // Show streaming preview of content (tail)
203
+ text += formatStreamingContent(args.content, uiTheme, ui);
204
+
205
+ return new Text(text, 0, 0);
182
206
  },
183
207
 
184
208
  renderResult(
185
209
  result: { content: Array<{ type: string; text?: string }>; details?: WriteToolDetails },
186
- { expanded, isPartial, spinnerFrame }: RenderResultOptions,
210
+ { expanded }: RenderResultOptions,
187
211
  uiTheme: Theme,
188
212
  args?: WriteRenderArgs,
189
213
  ): Component {
214
+ const ui = new ToolUIKit(uiTheme);
190
215
  const rawPath = args?.file_path || args?.path || "";
191
216
  const filePath = shortenPath(rawPath);
192
217
  const fileContent = args?.content || "";
193
218
  const lang = getLanguageFromPath(rawPath);
194
- const outputLines: string[] = [];
219
+ const langIcon = uiTheme.fg("muted", uiTheme.getLangIcon(lang));
220
+ const pathDisplay = filePath ? uiTheme.fg("accent", filePath) : uiTheme.fg("toolOutput", uiTheme.format.ellipsis);
195
221
  const lineCount = countLines(fileContent);
196
222
 
197
- outputLines.push(formatMetadataLine(lineCount, lang ?? "text", uiTheme));
223
+ // Build header with status icon
224
+ const header = renderStatusLine(
225
+ {
226
+ icon: "success",
227
+ title: "Write",
228
+ description: `${langIcon} ${pathDisplay}`,
229
+ },
230
+ uiTheme,
231
+ );
232
+ let text = header;
198
233
 
199
- if (isPartial && fileContent) {
200
- const contentLines = fileContent.split("\n");
201
- const displayLines = contentLines.slice(-WRITE_STREAMING_PREVIEW_LINES);
202
- const hidden = contentLines.length - displayLines.length;
203
- if (hidden > 0) {
204
- outputLines.push(uiTheme.fg("dim", `${uiTheme.format.ellipsis} (${hidden} earlier lines)`));
205
- }
206
- outputLines.push(uiTheme.fg("dim", `${uiTheme.format.ellipsis} (streaming)`));
234
+ // Add metadata line
235
+ text += `\n${formatMetadataLine(lineCount, lang ?? "text", uiTheme)}`;
207
236
 
208
- return {
209
- render: (width: number) =>
210
- renderCodeCell(
211
- {
212
- code: displayLines.join("\n"),
213
- language: lang,
214
- title: filePath ? `Write ${filePath}` : "Write",
215
- status: spinnerFrame !== undefined ? "running" : "pending",
216
- spinnerFrame,
217
- output: outputLines.join("\n"),
218
- codeMaxLines: WRITE_STREAMING_PREVIEW_LINES,
219
- expanded: true,
220
- width,
221
- },
222
- uiTheme,
223
- ),
224
- invalidate: () => {},
225
- };
226
- }
237
+ // Show content preview (collapsed tail, expandable)
238
+ text += renderContentPreview(fileContent, expanded, uiTheme, ui);
227
239
 
240
+ // Show diagnostics if available
228
241
  if (result.details?.diagnostics) {
229
242
  const diagText = formatDiagnostics(result.details.diagnostics, expanded, uiTheme, fp =>
230
243
  uiTheme.getLangIcon(getLanguageFromPath(fp)),
@@ -232,27 +245,13 @@ export const writeToolRenderer = {
232
245
  if (diagText.trim()) {
233
246
  const diagLines = diagText.split("\n");
234
247
  const firstNonEmpty = diagLines.findIndex(line => line.trim());
235
- outputLines.push(...(firstNonEmpty >= 0 ? diagLines.slice(firstNonEmpty) : []));
248
+ if (firstNonEmpty >= 0) {
249
+ text += `\n${diagLines.slice(firstNonEmpty).join("\n")}`;
250
+ }
236
251
  }
237
252
  }
238
253
 
239
- return {
240
- render: (width: number) =>
241
- renderCodeCell(
242
- {
243
- code: fileContent,
244
- language: lang,
245
- title: filePath ? `Write ${filePath}` : "Write",
246
- status: "complete",
247
- output: outputLines.join("\n"),
248
- codeMaxLines: expanded ? Number.POSITIVE_INFINITY : 10,
249
- expanded,
250
- width,
251
- },
252
- uiTheme,
253
- ),
254
- invalidate: () => {},
255
- };
254
+ return new Text(text, 0, 0);
256
255
  },
257
256
  mergeCallAndResult: true,
258
257
  };
@@ -12,10 +12,11 @@ export interface OutputBlockOptions {
12
12
  state?: State;
13
13
  sections?: Array<{ label?: string; lines: string[] }>;
14
14
  width: number;
15
+ applyBg?: boolean;
15
16
  }
16
17
 
17
18
  export function renderOutputBlock(options: OutputBlockOptions, theme: Theme): string[] {
18
- const { header, headerMeta, state, sections = [], width } = options;
19
+ const { header, headerMeta, state, sections = [], width, applyBg = true } = options;
19
20
  const h = theme.boxSharp.horizontal;
20
21
  const v = theme.boxSharp.vertical;
21
22
  const cap = h.repeat(3);
@@ -30,24 +31,31 @@ export function renderOutputBlock(options: OutputBlockOptions, theme: Theme): st
30
31
  ? "accent"
31
32
  : "dim";
32
33
  const border = (text: string) => theme.fg(borderColor, text);
33
- const bgFn = state ? (text: string) => theme.bg(getStateBgColor(state), text) : undefined;
34
+ const bgFn = state && applyBg ? (text: string) => theme.bg(getStateBgColor(state), text) : undefined;
34
35
 
35
- const buildBarLine = (leftChar: string, label?: string, meta?: string): string => {
36
+ const buildBarLine = (leftChar: string, rightChar: string, label?: string, meta?: string): string => {
36
37
  const left = border(`${leftChar}${cap}`);
37
- if (lineWidth <= 0) return left;
38
+ const right = border(rightChar);
39
+ if (lineWidth <= 0) return left + right;
38
40
  const labelText = [label, meta].filter(Boolean).join(theme.sep.dot);
39
41
  const rawLabel = labelText ? ` ${labelText} ` : " ";
40
- const maxLabelWidth = Math.max(0, lineWidth - visibleWidth(left));
42
+ const leftWidth = visibleWidth(left);
43
+ const rightWidth = visibleWidth(right);
44
+ const maxLabelWidth = Math.max(0, lineWidth - leftWidth - rightWidth);
41
45
  const trimmedLabel = truncateToWidth(rawLabel, maxLabelWidth, theme.format.ellipsis);
42
- const fillCount = Math.max(0, lineWidth - visibleWidth(left + trimmedLabel));
43
- return `${left}${trimmedLabel}${border(h.repeat(fillCount))}`;
46
+ const labelWidth = visibleWidth(trimmedLabel);
47
+ const fillCount = Math.max(0, lineWidth - leftWidth - labelWidth - rightWidth);
48
+ return `${left}${trimmedLabel}${border(h.repeat(fillCount))}${right}`;
44
49
  };
45
50
 
46
51
  const contentPrefix = border(`${v} `);
47
- const contentWidth = Math.max(0, lineWidth - visibleWidth(contentPrefix));
52
+ const contentSuffix = border(v);
53
+ const contentWidth = Math.max(0, lineWidth - visibleWidth(contentPrefix) - visibleWidth(contentSuffix));
48
54
  const lines: string[] = [];
49
55
 
50
- lines.push(padToWidth(buildBarLine(theme.boxSharp.topLeft, header, headerMeta), lineWidth, bgFn));
56
+ lines.push(
57
+ padToWidth(buildBarLine(theme.boxSharp.topLeft, theme.boxSharp.topRight, header, headerMeta), lineWidth, bgFn),
58
+ );
51
59
 
52
60
  const hasSections = sections.length > 0;
53
61
  const normalizedSections = hasSections ? sections : [{ lines: [] }];
@@ -55,17 +63,22 @@ export function renderOutputBlock(options: OutputBlockOptions, theme: Theme): st
55
63
  for (let i = 0; i < normalizedSections.length; i++) {
56
64
  const section = normalizedSections[i];
57
65
  if (section.label) {
58
- lines.push(padToWidth(buildBarLine(theme.boxSharp.teeRight, section.label), lineWidth, bgFn));
66
+ lines.push(
67
+ padToWidth(buildBarLine(theme.boxSharp.teeRight, theme.boxSharp.teeLeft, section.label), lineWidth, bgFn),
68
+ );
59
69
  }
60
70
  for (const line of section.lines) {
61
71
  const text = truncateToWidth(line, contentWidth, theme.format.ellipsis);
62
- lines.push(padToWidth(`${contentPrefix}${text}`, lineWidth, bgFn));
72
+ const innerPadding = " ".repeat(Math.max(0, contentWidth - visibleWidth(text)));
73
+ const fullLine = `${contentPrefix}${text}${innerPadding}${contentSuffix}`;
74
+ lines.push(padToWidth(fullLine, lineWidth, bgFn));
63
75
  }
64
76
  }
65
77
 
66
78
  const bottomLeft = border(`${theme.boxSharp.bottomLeft}${cap}`);
67
- const bottomFillCount = Math.max(0, lineWidth - visibleWidth(bottomLeft));
68
- const bottomLine = `${bottomLeft}${border(h.repeat(bottomFillCount))}`;
79
+ const bottomRight = border(theme.boxSharp.bottomRight);
80
+ const bottomFillCount = Math.max(0, lineWidth - visibleWidth(bottomLeft) - visibleWidth(bottomRight));
81
+ const bottomLine = `${bottomLeft}${border(h.repeat(bottomFillCount))}${bottomRight}`;
69
82
  lines.push(padToWidth(bottomLine, lineWidth, bgFn));
70
83
 
71
84
  return lines;