mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-31 08:13:11 +03:00
fix(tui): reduce redundant runtime chrome
This commit is contained in:
+5
-1
@@ -15,7 +15,7 @@ Standalone terminals use OpenTUI's retained full-screen layout: the transcript r
|
||||
|
||||
## Herdr host mode
|
||||
|
||||
When Herdr supplies `HERDR_ENV=1` and `HERDR_PANE_ID`, nanobot becomes a quiet hosted client. It uses OpenTUI's main-screen mode instead of hiding the whole run in a temporary alternate screen, removes the launch card and persistent session/model chrome, and keeps only the transcript, last user task, current progress, and composer. Herdr remains responsible for workspace, tab, pane, and attention navigation, while nanobot keeps its application-level session, new-chat, and branch commands.
|
||||
When Herdr supplies `HERDR_ENV=1` and `HERDR_PANE_ID`, nanobot becomes a quiet hosted client. It uses OpenTUI's main-screen mode instead of hiding the whole run in a temporary alternate screen, removes the launch card and persistent session/model/task chrome, and keeps only the transcript, compact progress, and composer. Herdr remains responsible for workspace, tab, pane, task, and attention navigation, while nanobot keeps its application-level session, new-chat, and branch commands.
|
||||
|
||||
The TUI reports its WebSocket session ID, model, Git branch, workspace, last task, and current action through Herdr's supported pane CLI. Sending work reports `working`; a persisted explicit nanobot goal block reports `blocked`; a completed turn reports `idle`; exit releases lifecycle authority. The gateway session remains the durable transcript and resume path. Standalone terminals keep the richer full-screen navigation described below.
|
||||
|
||||
@@ -27,6 +27,10 @@ When you scroll away from the latest output, the scrollbar and `Ctrl+End` hint a
|
||||
you return to the bottom. Large pastes are represented by a short editable placeholder in the
|
||||
composer; nanobot sends the original text unchanged.
|
||||
|
||||
While a turn is running, the composer remains available for steering and uses `Steer this turn…`
|
||||
as its prompt. The footer shows the lifecycle and elapsed time without repeating the latest tool
|
||||
activity already visible in the transcript.
|
||||
|
||||
Type `/` to discover slash commands published by the connected gateway. Use the arrow keys
|
||||
to move, `Tab` to complete, and `Esc` to close the menu.
|
||||
|
||||
|
||||
+59
-7
@@ -1,5 +1,5 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { CliRenderEvents, TextareaRenderable, TextRenderable } from "@opentui/core"
|
||||
import { BoxRenderable, CliRenderEvents, TextareaRenderable, TextRenderable } from "@opentui/core"
|
||||
import {
|
||||
MockTreeSitterClient,
|
||||
createTestRenderer,
|
||||
@@ -1279,9 +1279,9 @@ describe("NanobotTui layout", () => {
|
||||
expect(setup.renderer.width).toBe(width)
|
||||
expect(setup.renderer.height).toBe(height)
|
||||
expect(frame).not.toContain("undefined")
|
||||
expect(occurrences(frame, "Ask nanobot anything")).toBeLessThanOrEqual(1)
|
||||
expect(occurrences(frame, "Steer this turn…")).toBeLessThanOrEqual(1)
|
||||
if (width >= 30 && height >= 9) {
|
||||
expect(occurrences(frame, "Ask nanobot anything")).toBe(1)
|
||||
expect(occurrences(frame, "Steer this turn…")).toBe(1)
|
||||
}
|
||||
expect(occurrences(frame, "nanobot · test/model")).toBe(height >= 14 ? 1 : 0)
|
||||
}
|
||||
@@ -1774,13 +1774,18 @@ describe("NanobotTui layout", () => {
|
||||
expect(frame).toMatch(/Thinking\s+0s/u)
|
||||
expect(frame).not.toMatch(/[◐◓◑◒⠋⠙⠹⠸]/u)
|
||||
expect(frame).not.toContain("hidden reasoning")
|
||||
const status = (app as unknown as {
|
||||
const ui = app as unknown as {
|
||||
status: {
|
||||
content: { chunks: Array<{ fg?: { toInts(): number[] } }> }
|
||||
plainText: string
|
||||
}
|
||||
}).status
|
||||
composer: TextareaRenderable
|
||||
composerFrame: BoxRenderable
|
||||
}
|
||||
const status = ui.status
|
||||
expect(status.plainText).toMatch(/^Thinking\s+0s/u)
|
||||
expect(ui.composer.placeholder).toBe("Steer this turn…")
|
||||
expect(ui.composerFrame.height).toBe(3)
|
||||
const shimmerColors = new Set(
|
||||
status.content.chunks
|
||||
.slice(0, "Thinking".length)
|
||||
@@ -1803,7 +1808,11 @@ describe("NanobotTui layout", () => {
|
||||
expect(frame).toMatch(/Working\s+0s/u)
|
||||
expect(frame).not.toMatch(/[◐◓◑◒⠋⠙⠹⠸]/u)
|
||||
expect(frame).toContain("› Running pwd")
|
||||
expect(occurrences(frame, "pwd")).toBe(1)
|
||||
expect(status.plainText).not.toContain("pwd")
|
||||
app.accept({ event: "turn_end", chat_id: "chat" })
|
||||
await setup.flush()
|
||||
expect(ui.composer.placeholder).toBe("Ask nanobot anything")
|
||||
})
|
||||
|
||||
test("folds long tool traces without discarding their details", async () => {
|
||||
@@ -1823,8 +1832,9 @@ describe("NanobotTui layout", () => {
|
||||
await setup.renderOnce()
|
||||
let frame = setup.captureCharFrame()
|
||||
|
||||
expect(frame).toContain("5 earlier steps · Ctrl+O expand")
|
||||
expect(frame).toContain("7 earlier steps · Ctrl+O expand")
|
||||
expect(frame).not.toContain("tool_0")
|
||||
expect(frame).toContain("tool_7")
|
||||
expect(frame).toContain("tool_9")
|
||||
|
||||
setup.mockInput.pressKey("O", { ctrl: true })
|
||||
@@ -1860,6 +1870,37 @@ describe("NanobotTui layout", () => {
|
||||
app.accept({ event: "turn_end", chat_id: "chat" })
|
||||
})
|
||||
|
||||
test("groups consecutive file activity only in the collapsed preview", async () => {
|
||||
setup = await createRenderer({ width: 72, height: 20, screenMode: "alternate-screen" })
|
||||
const app = mount(setup)
|
||||
app.accept({
|
||||
event: "message",
|
||||
chat_id: "chat",
|
||||
text: "file progress",
|
||||
kind: "tool_hint",
|
||||
tool_events: Array.from({ length: 6 }, (_, index) => ({
|
||||
phase: "end" as const,
|
||||
call_id: `read-${index}`,
|
||||
name: "read_file",
|
||||
arguments: { path: `/tmp/nanobot-workspace/src/file-${index}.ts` },
|
||||
})),
|
||||
})
|
||||
await setup.renderOnce()
|
||||
let frame = setup.captureCharFrame()
|
||||
|
||||
expect(frame).toContain("6 steps · Ctrl+O expand")
|
||||
expect(frame).toContain("✓ Read 6 files")
|
||||
expect(frame).not.toContain("src/file-0.ts")
|
||||
|
||||
setup.mockInput.pressKey("O", { ctrl: true })
|
||||
await setup.renderOnce()
|
||||
frame = setup.captureCharFrame()
|
||||
|
||||
expect(frame).not.toContain("Read 6 files")
|
||||
expect(frame).toContain("src/file-0.ts")
|
||||
expect(frame).toContain("src/file-5.ts")
|
||||
})
|
||||
|
||||
test("supports keyboard transcript navigation without rebuilding the layout", async () => {
|
||||
setup = await createRenderer({ width: 64, height: 16, screenMode: "alternate-screen" })
|
||||
const app = mount(setup)
|
||||
@@ -2235,6 +2276,10 @@ describe("NanobotTui in a Herdr pane", () => {
|
||||
new MockTreeSitterClient({ autoResolveTimeout: 0 }),
|
||||
host,
|
||||
)
|
||||
const ui = app as unknown as {
|
||||
composer: TextareaRenderable
|
||||
composerFrame: BoxRenderable
|
||||
}
|
||||
|
||||
await setup.mockInput.typeText("/")
|
||||
await setup.flush()
|
||||
@@ -2243,6 +2288,7 @@ describe("NanobotTui in a Herdr pane", () => {
|
||||
expect(commandFrame).toContain("/new-chat")
|
||||
expect(commandFrame).toContain("/branch")
|
||||
setup.mockInput.pressEscape()
|
||||
ui.composer.setText("")
|
||||
|
||||
app.accept({ event: "attached", chat_id: "chat" })
|
||||
app.accept({
|
||||
@@ -2259,6 +2305,12 @@ describe("NanobotTui in a Herdr pane", () => {
|
||||
kind: "tool_hint",
|
||||
tool_events: [{ phase: "end", call_id: "read", name: "read_file", arguments: { path: "app.ts" } }],
|
||||
})
|
||||
await setup.flush()
|
||||
const activeFrame = setup.captureCharFrame()
|
||||
expect(occurrences(activeFrame, "› Ship the Herdr integration")).toBe(1)
|
||||
expect(occurrences(activeFrame, "app.ts")).toBe(1)
|
||||
expect(ui.composer.placeholder).toBe("Steer this turn…")
|
||||
expect(ui.composerFrame.height).toBe(3)
|
||||
app.accept({
|
||||
event: "turn_end",
|
||||
chat_id: "chat",
|
||||
@@ -2273,7 +2325,7 @@ describe("NanobotTui in a Herdr pane", () => {
|
||||
const frame = setup.captureCharFrame()
|
||||
|
||||
expect(sessions).toEqual(["chat"])
|
||||
expect(frame).toContain("› Ship the Herdr integration")
|
||||
expect(occurrences(frame, "› Ship the Herdr integration")).toBe(1)
|
||||
expect(frame).not.toContain(">_ nanobot")
|
||||
expect(frame).not.toContain("test/model")
|
||||
expect(states.some(({ state }) => state === "working")).toBe(true)
|
||||
|
||||
+8
-11
@@ -160,6 +160,7 @@ const LIGHT: Palette = {
|
||||
}
|
||||
|
||||
const COMPOSER_PLACEHOLDER = "Ask nanobot anything"
|
||||
const ACTIVE_COMPOSER_PLACEHOLDER = "Steer this turn…"
|
||||
const SHIMMER_PAUSE = 16
|
||||
const SHIMMER_BAND = 4
|
||||
const SHIMMER_INTERVAL_MS = 80
|
||||
@@ -476,6 +477,7 @@ export class NanobotTui {
|
||||
treeSitterClient,
|
||||
(state) => this.handleTranscriptNavigation(state),
|
||||
!host.hosted,
|
||||
options.workspace,
|
||||
)
|
||||
this.commandMenu = new CommandMenu(renderer, commandMenuTheme(this.palette))
|
||||
this.commandMenu.setCommands([], LOCAL_COMMANDS)
|
||||
@@ -719,7 +721,7 @@ export class NanobotTui {
|
||||
this.shell.add(this.branchMenu.root)
|
||||
this.shell.add(this.contextPanel.root)
|
||||
this.shell.add(this.runtimeControls.menuRoot)
|
||||
this.shell.add(this.title)
|
||||
if (!host.hosted) this.shell.add(this.title)
|
||||
this.shell.add(this.queuePreview.root)
|
||||
this.shell.add(this.composerFrame)
|
||||
this.shell.add(statusRow)
|
||||
@@ -1262,6 +1264,7 @@ export class NanobotTui {
|
||||
}
|
||||
this.activeTurn = active
|
||||
this.updateMeta()
|
||||
this.syncComposerPlaceholder()
|
||||
if (active) {
|
||||
this.activeStartedAt = startedAt ?? Date.now()
|
||||
this.shimmerFrame = 0
|
||||
@@ -1280,14 +1283,11 @@ export class NanobotTui {
|
||||
|
||||
private renderActiveStatus(): void {
|
||||
const elapsed = formatElapsed(Date.now() - this.activeStartedAt)
|
||||
const progress = this.lastProgress
|
||||
? ` · ${this.lastProgress.replace(/^\s*[·›✓×]\s*/u, "")}`
|
||||
: ""
|
||||
const navigation = this.transcriptNavigation.awayFromBottom ? " · Ctrl+End latest" : ""
|
||||
const queued = this.promptQueue.length ? ` · ${this.promptQueue.length} queued` : ""
|
||||
this.status.content = shimmerStatus(
|
||||
this.activeLabel,
|
||||
` ${elapsed}${progress}${queued}${navigation}`,
|
||||
` ${elapsed}${queued}${navigation}`,
|
||||
this.shimmerFrame,
|
||||
this.palette,
|
||||
)
|
||||
@@ -1659,11 +1659,6 @@ export class NanobotTui {
|
||||
|
||||
private updateTitle(): void {
|
||||
if (this.host.hosted) {
|
||||
this.titleText.maxWidth = Math.max(8, this.renderer.width - 4)
|
||||
this.titleText.content = this.currentTask ? `› ${this.currentTask}` : ""
|
||||
// In a hosted pane this is the resume anchor, not decorative chrome.
|
||||
// Keep it visible even when Herdr temporarily makes the pane very short.
|
||||
this.title.visible = Boolean(this.currentTask)
|
||||
this.syncHostMetadata()
|
||||
return
|
||||
}
|
||||
@@ -1774,7 +1769,9 @@ export class NanobotTui {
|
||||
? null
|
||||
: this.sessionMenu.visible
|
||||
? "Search sessions"
|
||||
: this.branchMenu.visible ? "Search branch points" : COMPOSER_PLACEHOLDER
|
||||
: this.branchMenu.visible
|
||||
? "Search branch points"
|
||||
: this.activeTurn ? ACTIVE_COMPOSER_PLACEHOLDER : COMPOSER_PLACEHOLDER
|
||||
if (this.composer.placeholder !== placeholder) this.composer.placeholder = placeholder
|
||||
}
|
||||
|
||||
|
||||
@@ -26,4 +26,28 @@ describe("tool renderers", () => {
|
||||
expect(renderToolEvent({ phase: "end", name: "apply_patch", arguments: { path: "app.ts" } }))
|
||||
.toBe(" ✓ Edited app.ts")
|
||||
})
|
||||
|
||||
test("shortens file paths relative to the workspace while preserving the useful tail", () => {
|
||||
const workspace = String.raw`C:\workspace\nanobot`
|
||||
expect(renderToolEvent({
|
||||
phase: "end",
|
||||
name: "read_file",
|
||||
arguments: { path: String.raw`C:\workspace\nanobot\tui\src\app.ts` },
|
||||
}, { workspace })).toBe(" ✓ Read tui/src/app.ts")
|
||||
expect(renderToolEvent({
|
||||
phase: "end",
|
||||
name: "read_file",
|
||||
arguments: {
|
||||
path: String.raw`C:\workspace\nanobot\.worktrees\feature\nanobot\providers\fallback_provider.py`,
|
||||
},
|
||||
}, { workspace })).toBe(" ✓ Read …/feature/nanobot/providers/fallback_provider.py")
|
||||
})
|
||||
|
||||
test("summarizes subagent delegation without exposing raw argument JSON", () => {
|
||||
expect(renderToolEvent({
|
||||
phase: "end",
|
||||
name: "spawn",
|
||||
arguments: { task: "Simplify the fallback provider implementation" },
|
||||
})).toBe(" ✓ Delegated Simplify the fallback provider implementation")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import type { ToolProgressEvent } from "./protocol"
|
||||
|
||||
export interface ToolRenderOptions {
|
||||
workspace?: string
|
||||
}
|
||||
|
||||
const PATH_DETAIL_LIMIT = 52
|
||||
|
||||
export function mergeToolEvent(
|
||||
previous: ToolProgressEvent | undefined,
|
||||
next: ToolProgressEvent,
|
||||
@@ -14,13 +20,18 @@ export function mergeToolEvent(
|
||||
}
|
||||
}
|
||||
|
||||
export function renderToolEvent(event: ToolProgressEvent): string {
|
||||
export function renderToolEvent(
|
||||
event: ToolProgressEvent,
|
||||
options: ToolRenderOptions = {},
|
||||
): string {
|
||||
const phase = event.phase || "start"
|
||||
const marker = phase === "error" ? "×" : phase === "end" ? "✓" : "›"
|
||||
const name = (event.name || "tool").trim()
|
||||
const args = record(event.arguments)
|
||||
const result = record(event.result)
|
||||
const detail = phase === "error" ? compact(event.error) : toolDetail(name, args, result)
|
||||
const detail = phase === "error"
|
||||
? compact(event.error)
|
||||
: toolDetail(name, args, result, options)
|
||||
return ` ${marker} ${toolLabel(name, phase, args)}${detail ? ` ${detail}` : ""}`
|
||||
}
|
||||
|
||||
@@ -39,6 +50,9 @@ function toolLabel(
|
||||
if (/^(?:read_file|read)$/u.test(name)) return "Read"
|
||||
if (/^(?:write_file|write)$/u.test(name)) return phase === "end" ? "Edited" : "Editing"
|
||||
if (/^(?:edit_file|apply_patch|edit)$/u.test(name)) return phase === "end" ? "Edited" : "Editing"
|
||||
if (/^(?:spawn|spawn_agent)$/u.test(name)) {
|
||||
return phase === "end" ? "Delegated" : phase === "error" ? "Delegation failed" : "Delegating"
|
||||
}
|
||||
if (name === "web_search") return "Search web"
|
||||
if (name === "web_fetch") return "Fetch"
|
||||
return name
|
||||
@@ -48,13 +62,15 @@ function toolDetail(
|
||||
name: string,
|
||||
args: Record<string, unknown>,
|
||||
result: Record<string, unknown>,
|
||||
options: ToolRenderOptions,
|
||||
): string {
|
||||
if (/^(?:exec|exec_command|shell|run_command)$/u.test(name)) {
|
||||
return compact(args.command ?? args.cmd ?? result.output)
|
||||
}
|
||||
if (/^(?:read_file|write_file|edit_file|apply_patch|read|write|edit)$/u.test(name)) {
|
||||
return compact(args.path ?? args.file_path ?? result.path)
|
||||
return compactPath(args.path ?? args.file_path ?? result.path, options.workspace)
|
||||
}
|
||||
if (/^(?:spawn|spawn_agent)$/u.test(name)) return compact(args.label ?? args.task, 56)
|
||||
if (name === "web_search") return compact(args.query ?? args.q)
|
||||
if (name === "web_fetch") return compact(args.url)
|
||||
if (/session/u.test(name)) return compact(args.session_key ?? args.chat_id ?? args.query)
|
||||
@@ -68,8 +84,40 @@ function record(value: unknown): Record<string, unknown> {
|
||||
: {}
|
||||
}
|
||||
|
||||
function compact(value: unknown): string {
|
||||
function compact(value: unknown, limit = 88): string {
|
||||
if (value == null || value === "") return ""
|
||||
const text = typeof value === "string" ? value : JSON.stringify(value)
|
||||
return text.length > 88 ? `${text.slice(0, 85)}…` : text
|
||||
const serialized = typeof value === "string" ? value : JSON.stringify(value)
|
||||
const text = (serialized || String(value)).replace(/\s+/gu, " ").trim()
|
||||
return text.length > limit ? `${text.slice(0, limit - 1)}…` : text
|
||||
}
|
||||
|
||||
function compactPath(value: unknown, workspace?: string): string {
|
||||
if (value == null || value === "") return ""
|
||||
const normalized = String(value).replace(/\\/gu, "/").replace(/\/+$/u, "")
|
||||
const relative = workspaceRelativePath(normalized, workspace)
|
||||
if (relative.length <= PATH_DETAIL_LIMIT) return relative
|
||||
|
||||
const parts = relative.split("/").filter(Boolean)
|
||||
let tail = parts.pop() || relative
|
||||
if (tail.length + 1 >= PATH_DETAIL_LIMIT) {
|
||||
return `…${tail.slice(-(PATH_DETAIL_LIMIT - 1))}`
|
||||
}
|
||||
while (parts.length) {
|
||||
const candidate = `${parts.at(-1)}/${tail}`
|
||||
if (`…/${candidate}`.length > PATH_DETAIL_LIMIT) break
|
||||
tail = candidate
|
||||
parts.pop()
|
||||
}
|
||||
return `…/${tail}`
|
||||
}
|
||||
|
||||
function workspaceRelativePath(path: string, workspace?: string): string {
|
||||
if (!workspace) return path
|
||||
const base = workspace.replace(/\\/gu, "/").replace(/\/+$/u, "")
|
||||
const caseInsensitive = /^[a-z]:\//iu.test(path) || /^[a-z]:\//iu.test(base)
|
||||
const comparedPath = caseInsensitive ? path.toLowerCase() : path
|
||||
const comparedBase = caseInsensitive ? base.toLowerCase() : base
|
||||
if (comparedPath === comparedBase) return path.split("/").at(-1) || path
|
||||
if (comparedPath.startsWith(`${comparedBase}/`)) return path.slice(base.length + 1)
|
||||
return path
|
||||
}
|
||||
|
||||
+33
-5
@@ -44,7 +44,13 @@ interface Activity {
|
||||
events: Map<string, ToolProgressEvent>
|
||||
}
|
||||
|
||||
const ACTIVITY_PREVIEW_LINES = 6
|
||||
interface ActivityPreviewItem {
|
||||
text: string
|
||||
steps: number
|
||||
group?: string
|
||||
}
|
||||
|
||||
const ACTIVITY_PREVIEW_LINES = 4
|
||||
// OpenTUI renders at 30 FPS. Re-parsing the entire Markdown buffer for every
|
||||
// provider token turns long answers into quadratic work without producing any
|
||||
// additional visible frames. Paint the first token immediately, then coalesce
|
||||
@@ -78,6 +84,7 @@ export class Transcript {
|
||||
private readonly treeSitterClient: TreeSitterClient,
|
||||
private readonly onNavigationChange?: (state: TranscriptNavigation) => void,
|
||||
private readonly showHeader = true,
|
||||
private readonly workspace = "",
|
||||
) {
|
||||
this.root = new ScrollBoxRenderable(renderer, {
|
||||
id: "nanobot-tui-transcript",
|
||||
@@ -424,7 +431,7 @@ export class Transcript {
|
||||
const key = event.call_id ? `tool:${event.call_id}` : ""
|
||||
const merged = key ? mergeToolEvent(activity.events.get(key), event) : event
|
||||
if (key) activity.events.set(key, merged)
|
||||
return { key, line: renderToolEvent(merged) }
|
||||
return { key, line: renderToolEvent(merged, { workspace: this.workspace }) }
|
||||
})
|
||||
const lines = events.length > 0
|
||||
? projected.map(({ line }) => line).filter(Boolean)
|
||||
@@ -448,9 +455,14 @@ export class Transcript {
|
||||
activity.text.content = activity.lines.join("\n")
|
||||
return
|
||||
}
|
||||
const visible = activity.lines.slice(-(ACTIVITY_PREVIEW_LINES - 1))
|
||||
const hidden = activity.lines.length - visible.length
|
||||
activity.text.content = [` … ${hidden} earlier steps · Ctrl+O expand`, ...visible].join("\n")
|
||||
const visible = activityPreview(activity.lines).slice(-(ACTIVITY_PREVIEW_LINES - 1))
|
||||
const visibleSteps = visible.reduce((total, item) => total + item.steps, 0)
|
||||
const hidden = activity.lines.length - visibleSteps
|
||||
const disclosure = hidden > 0 ? `${hidden} earlier steps` : `${activity.lines.length} steps`
|
||||
activity.text.content = [
|
||||
` … ${disclosure} · Ctrl+O expand`,
|
||||
...visible.map((item) => item.text),
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
private createText(
|
||||
@@ -542,6 +554,22 @@ function cleanProgress(value: string): string {
|
||||
return text ? ` · ${text}` : ""
|
||||
}
|
||||
|
||||
function activityPreview(lines: readonly string[]): ActivityPreviewItem[] {
|
||||
const preview: ActivityPreviewItem[] = []
|
||||
for (const line of lines) {
|
||||
const match = line.match(/^ ([›✓]) (Read|Edited|Editing) /u)
|
||||
const group = match ? `${match[1]}:${match[2]}` : undefined
|
||||
const previous = preview.at(-1)
|
||||
if (match && group && previous?.group === group) {
|
||||
previous.steps += 1
|
||||
previous.text = ` ${match[1]} ${match[2]} ${previous.steps} files`
|
||||
continue
|
||||
}
|
||||
preview.push({ text: line, steps: 1, ...(group ? { group } : {}) })
|
||||
}
|
||||
return preview
|
||||
}
|
||||
|
||||
function formatDiffStat(edit: FileEditEvent): string {
|
||||
const added = typeof edit.added === "number" ? `+${edit.added}` : ""
|
||||
const deleted = typeof edit.deleted === "number" ? `-${edit.deleted}` : ""
|
||||
|
||||
Reference in New Issue
Block a user