pi-minimal 0.4.0 → 0.5.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 +46 -0
- package/CONTEXT.md +83 -0
- package/README.md +3 -3
- package/dist/config.js +2 -1
- package/dist/edit-diff.js +74 -0
- package/dist/footer-contribution-events.js +5 -3
- package/dist/footer-contributions.js +15 -13
- package/dist/git-state.js +31 -11
- package/dist/index.js +24 -208
- package/dist/reclaim.js +0 -3
- package/dist/session.js +155 -0
- package/dist/tools.js +213 -374
- package/dist/tracker.js +2 -2
- package/dist/working-status.js +2 -1
- package/doc/archive/README.md +3 -0
- package/doc/footer-contributions.md +7 -7
- package/package.json +1 -1
- package/release-notes.md +46 -12
- package/src/config.ts +2 -1
- package/src/edit-diff.ts +73 -0
- package/src/footer-contribution-events.ts +7 -4
- package/src/footer-contributions.ts +15 -13
- package/src/git-state.ts +29 -10
- package/src/index.ts +26 -201
- package/src/reclaim.ts +0 -4
- package/src/session.ts +159 -0
- package/src/tools.ts +261 -389
- package/src/tracker.ts +11 -5
- package/src/working-status.ts +2 -1
- package/test/config.test.ts +2 -2
- package/test/footer-contribution-events.test.ts +15 -0
- package/test/git-state.test.ts +35 -0
- package/test/index.test.ts +45 -3
- package/test/tools.test.ts +36 -3
- package/test/working-status.test.ts +53 -0
- /package/doc/{plans/2026-09-22-pi-minimal-package.md → archive/2026-09-22-plan-pi-minimal-package.md} +0 -0
- /package/doc/{specs/2026-09-22-pi-minimal-package.md → archive/2026-09-22-spec-pi-minimal-package.md} +0 -0
package/src/tracker.ts
CHANGED
|
@@ -3,18 +3,24 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent"
|
|
|
3
3
|
const PHASES = ["brainstorm", "plan", "implement", "verify", "ship"] as const
|
|
4
4
|
type PhaseName = (typeof PHASES)[number]
|
|
5
5
|
type PhaseStatus = "pending" | "in_progress" | "complete" | "skipped"
|
|
6
|
-
|
|
6
|
+
/** pi-gauntlet's task statuses; distinct from the footer contribution API's `ActivityTask.status`. */
|
|
7
|
+
type GauntletTaskStatus = "pending" | "in_progress" | "complete" | "failed"
|
|
7
8
|
|
|
8
9
|
type PhaseMap = Record<PhaseName, { status: PhaseStatus; substep?: string | null }>
|
|
9
10
|
|
|
10
|
-
type Task = { name: string; status:
|
|
11
|
+
type Task = { name: string; status: GauntletTaskStatus }
|
|
11
12
|
|
|
12
|
-
|
|
13
|
+
/**
|
|
14
|
+
* What pi-gauntlet last reported on this session branch, read from its `phase_tracker` and
|
|
15
|
+
* `plan_tracker` tool results. A projection of another extension's history, not a contribution:
|
|
16
|
+
* nothing is registered or versioned, so it is only as stable as those tools' `details` shapes.
|
|
17
|
+
*/
|
|
18
|
+
export type GauntletProjection = {
|
|
13
19
|
phases?: PhaseMap
|
|
14
20
|
tasks?: Task[]
|
|
15
21
|
}
|
|
16
22
|
|
|
17
|
-
function
|
|
23
|
+
export function readGauntletProjection(ctx: ExtensionContext): GauntletProjection {
|
|
18
24
|
let phases: PhaseMap | undefined
|
|
19
25
|
let tasks: Task[] | undefined
|
|
20
26
|
for (const entry of ctx.sessionManager.getBranch()) {
|
|
@@ -47,7 +53,7 @@ export function formatTrackerLines(
|
|
|
47
53
|
ctx: ExtensionContext,
|
|
48
54
|
theme: { fg: (name: string, s: string) => string },
|
|
49
55
|
): string[] {
|
|
50
|
-
const { phases, tasks } =
|
|
56
|
+
const { phases, tasks } = readGauntletProjection(ctx)
|
|
51
57
|
const lines: string[] = []
|
|
52
58
|
if (phases && phaseActive(phases)) {
|
|
53
59
|
const parts = PHASES.map((p) => {
|
package/src/working-status.ts
CHANGED
|
@@ -206,8 +206,9 @@ export function installWorkingStatus(pi: ExtensionAPI): () => void {
|
|
|
206
206
|
})
|
|
207
207
|
|
|
208
208
|
pi.on("session_shutdown", () => {
|
|
209
|
-
ctx
|
|
209
|
+
// Clear the message while ctx is still known; dropping ctx first left it on screen.
|
|
210
210
|
stop()
|
|
211
|
+
ctx = undefined
|
|
211
212
|
})
|
|
212
213
|
|
|
213
214
|
return stop
|
package/test/config.test.ts
CHANGED
|
@@ -26,11 +26,11 @@ function writeProject(cwd: string, contents: string) {
|
|
|
26
26
|
writeFileSync(join(dir, "pi-minimal.json"), contents)
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
test("defaults hideGauntletWidgets to
|
|
29
|
+
test("defaults hideGauntletWidgets to false", (t) => {
|
|
30
30
|
const { agentDir, cwd } = setup(t)
|
|
31
31
|
const error = t.mock.method(console, "error")
|
|
32
32
|
const config = loadPiMinimalConfig({ agentDir, cwd, projectTrusted: true })
|
|
33
|
-
assert.deepEqual(config, { hideGauntletWidgets:
|
|
33
|
+
assert.deepEqual(config, { hideGauntletWidgets: false, editDiff: "expanded" })
|
|
34
34
|
assert.equal(error.mock.callCount(), 0)
|
|
35
35
|
})
|
|
36
36
|
|
|
@@ -65,3 +65,18 @@ test("shutdown removes listeners, invalidates handles and emits unavailable once
|
|
|
65
65
|
assert.equal(fresh.result, undefined)
|
|
66
66
|
assert.deepEqual(handle.publish({}), { ok: false, code: "disposed" })
|
|
67
67
|
})
|
|
68
|
+
|
|
69
|
+
test("the plan slot accepts both its name and the legacy alias, and advertises all three", () => {
|
|
70
|
+
const events = bus(), registry = createFooterContributions()
|
|
71
|
+
let ready: { slots: string[] } | undefined
|
|
72
|
+
events.on(FOOTER_EVENTS.ready, (value) => { ready = value as { slots: string[] } })
|
|
73
|
+
bindFooterContributionEvents(events, registry, "S1", "tui")
|
|
74
|
+
assert.ok(ready)
|
|
75
|
+
assert.deepEqual(ready!.slots, ["activity", "plan", "summary"])
|
|
76
|
+
const viaPlan: RegisterRequest = { ...request(), slot: "plan", owner: "a" }
|
|
77
|
+
events.emit(FOOTER_EVENTS.register, viaPlan)
|
|
78
|
+
assert.ok(viaPlan.result?.ok)
|
|
79
|
+
const viaSummary: RegisterRequest = { ...request(), slot: "summary", owner: "b" }
|
|
80
|
+
events.emit(FOOTER_EVENTS.register, viaSummary)
|
|
81
|
+
assert.deepEqual(viaSummary.result, { ok: false, code: "occupied" }, "both names address the same slot")
|
|
82
|
+
})
|
package/test/git-state.test.ts
CHANGED
|
@@ -50,3 +50,38 @@ test("createGitState clears state outside a repository", async () => {
|
|
|
50
50
|
await git.refresh(true)
|
|
51
51
|
assert.equal(git.get(), undefined)
|
|
52
52
|
})
|
|
53
|
+
|
|
54
|
+
test("createGitState reads cwd once per refresh, drops results after dispose, and isolates listener errors", async () => {
|
|
55
|
+
const cwds: string[] = []
|
|
56
|
+
let resolveStatus!: (value: unknown) => void
|
|
57
|
+
const pi = {
|
|
58
|
+
async exec(_cmd: string, args: string[], opts: { cwd: string }) {
|
|
59
|
+
cwds.push(opts.cwd)
|
|
60
|
+
if (args[0] === "rev-parse") return { stdout: "main\n", stderr: "", code: 0, killed: false }
|
|
61
|
+
return new Promise(resolve => { resolveStatus = resolve })
|
|
62
|
+
},
|
|
63
|
+
} as unknown as ExtensionAPI
|
|
64
|
+
let dir = "/a"
|
|
65
|
+
const git = createGitState(pi, () => dir)
|
|
66
|
+
let notified = 0
|
|
67
|
+
git.onChange(() => { throw new Error("boom") })
|
|
68
|
+
git.onChange(() => { notified++ })
|
|
69
|
+
const pending = git.refresh(true)
|
|
70
|
+
await new Promise(resolve => setImmediate(resolve))
|
|
71
|
+
dir = "/b"
|
|
72
|
+
resolveStatus({ stdout: " M x\n", stderr: "", code: 0, killed: false })
|
|
73
|
+
await pending
|
|
74
|
+
assert.deepEqual(cwds, ["/a", "/a"], "both commands use the directory read at refresh start")
|
|
75
|
+
assert.deepEqual(git.get(), { branch: "main", dirty: 1 })
|
|
76
|
+
assert.equal(notified, 1, "a throwing listener does not block the next one")
|
|
77
|
+
|
|
78
|
+
const late = git.refresh(true)
|
|
79
|
+
await new Promise(resolve => setImmediate(resolve))
|
|
80
|
+
git.dispose()
|
|
81
|
+
resolveStatus({ stdout: "", stderr: "", code: 0, killed: false })
|
|
82
|
+
await late
|
|
83
|
+
assert.deepEqual(git.get(), { branch: "main", dirty: 1 }, "a result landing after dispose is ignored")
|
|
84
|
+
assert.equal(notified, 1)
|
|
85
|
+
await git.refresh(true)
|
|
86
|
+
assert.deepEqual(cwds.length, 4, "no new commands after dispose")
|
|
87
|
+
})
|
package/test/index.test.ts
CHANGED
|
@@ -132,7 +132,7 @@ function herdrProbes(execCalls: Call[]): Call[] {
|
|
|
132
132
|
}
|
|
133
133
|
|
|
134
134
|
test.describe("session chrome", { concurrency: 1 }, () => {
|
|
135
|
-
test("TUI session_start installs chrome and
|
|
135
|
+
test("TUI session_start installs chrome and leaves Gauntlet widgets alone by default", async (t) => {
|
|
136
136
|
const { agentDir, cwd } = setupDirs(t)
|
|
137
137
|
process.env[AGENT_DIR_ENV] = agentDir
|
|
138
138
|
delete process.env.HERDR_ENV
|
|
@@ -155,8 +155,8 @@ test.describe("session chrome", { concurrency: 1 }, () => {
|
|
|
155
155
|
(call) => typeof call.args[1] === "function" || (call.args[1] !== undefined && call.args[1] !== null),
|
|
156
156
|
),
|
|
157
157
|
)
|
|
158
|
-
assert.
|
|
159
|
-
assert.
|
|
158
|
+
assert.equal(widgetCalls(uiCalls, "phase_tracker").length, 0)
|
|
159
|
+
assert.equal(widgetCalls(uiCalls, "plan_tracker").length, 0)
|
|
160
160
|
assert.equal(
|
|
161
161
|
allCalls.some((call) => call.method === "setHideThinkingBlock"),
|
|
162
162
|
false,
|
|
@@ -200,6 +200,26 @@ test.describe("session chrome", { concurrency: 1 }, () => {
|
|
|
200
200
|
assert.equal(allCalls.some((call) => call.method === "registerTool"), false, "built-in tools are not re-registered")
|
|
201
201
|
})
|
|
202
202
|
|
|
203
|
+
test("clears Gauntlet widgets on every chrome reassertion when hiding is opted in, and never on a timer", async (t) => {
|
|
204
|
+
const { agentDir, cwd } = setupDirs(t)
|
|
205
|
+
writeFileSync(join(agentDir, "pi-minimal.json"), JSON.stringify({ hideGauntletWidgets: true }))
|
|
206
|
+
process.env[AGENT_DIR_ENV] = agentDir
|
|
207
|
+
delete process.env.HERDR_ENV
|
|
208
|
+
t.after(restoreEnv)
|
|
209
|
+
|
|
210
|
+
const { pi, uiCalls, emit } = createFake({ cwd, projectTrusted: true })
|
|
211
|
+
install(pi)
|
|
212
|
+
t.after(() => emit("session_shutdown"))
|
|
213
|
+
await emit("session_start")
|
|
214
|
+
await new Promise(resolve => setTimeout(resolve, 60))
|
|
215
|
+
const afterSettle = widgetCalls(uiCalls, "phase_tracker").length
|
|
216
|
+
assert.ok(afterSettle >= 1, "hidden at session_start")
|
|
217
|
+
await new Promise(resolve => setTimeout(resolve, 450))
|
|
218
|
+
assert.equal(widgetCalls(uiCalls, "phase_tracker").length, afterSettle, "no steal interval")
|
|
219
|
+
await emit("turn_end")
|
|
220
|
+
assert.equal(widgetCalls(uiCalls, "phase_tracker").length, afterSettle + 1, "hidden again on turn_end")
|
|
221
|
+
})
|
|
222
|
+
|
|
203
223
|
test("does not clear Gauntlet widgets when config disables hiding", async (t) => {
|
|
204
224
|
const { agentDir, cwd } = setupDirs(t)
|
|
205
225
|
writeFileSync(join(agentDir, "pi-minimal.json"), JSON.stringify({ hideGauntletWidgets: false }))
|
|
@@ -329,3 +349,25 @@ test("agent_end appends a turn summary entry in TUI mode", async (t) => {
|
|
|
329
349
|
assert.equal(data.outcome, "completed")
|
|
330
350
|
assert.ok(registerCalls.some((c) => c.method === "registerEntryRenderer" && c.args[0] === "pi-minimal.turn"))
|
|
331
351
|
})
|
|
352
|
+
|
|
353
|
+
test("a second session_start disposes the first session's chrome and the stale one cannot draw", async (t) => {
|
|
354
|
+
const { agentDir, cwd } = setupDirs(t)
|
|
355
|
+
process.env[AGENT_DIR_ENV] = agentDir
|
|
356
|
+
delete process.env.HERDR_ENV
|
|
357
|
+
t.after(restoreEnv)
|
|
358
|
+
const { pi, emit, events, uiCalls } = createFake({ cwd })
|
|
359
|
+
install(pi)
|
|
360
|
+
t.after(() => emit("session_shutdown"))
|
|
361
|
+
await emit("session_start")
|
|
362
|
+
const first: RegisterRequest = { version: 1, owner: "a", sessionId: "S1", slot: "activity" }
|
|
363
|
+
events.emit(FOOTER_EVENTS.register, first)
|
|
364
|
+
assert.ok(first.result?.ok)
|
|
365
|
+
await emit("session_start")
|
|
366
|
+
assert.equal(first.result.registration.publish({ revision: 1, rows: [], omitted: 0, motion: "static", format: "icons" }).ok, false, "old registration is revoked")
|
|
367
|
+
const second: RegisterRequest = { version: 1, owner: "a", sessionId: "S1", slot: "activity" }
|
|
368
|
+
events.emit(FOOTER_EVENTS.register, second)
|
|
369
|
+
assert.ok(second.result?.ok, "the new session accepts a fresh registration")
|
|
370
|
+
const count = uiCalls.length
|
|
371
|
+
await new Promise(resolve => setTimeout(resolve, 70))
|
|
372
|
+
assert.ok(uiCalls.length >= count, "settle callbacks of the live session still run")
|
|
373
|
+
})
|
package/test/tools.test.ts
CHANGED
|
@@ -192,10 +192,15 @@ function editRows(expanded: boolean, result: unknown = editResult, isError = fal
|
|
|
192
192
|
return block.render(80).map((line) => line.trimEnd())
|
|
193
193
|
}
|
|
194
194
|
|
|
195
|
-
|
|
195
|
+
const longDiff = (n: number) => ({ content: [{ type: "text", text: "ok" }], details: { diff: Array.from({ length: n }, (_, i) => `+${i + 1} line ${i}`).join("\n") } })
|
|
196
|
+
|
|
197
|
+
test("editDiff=expanded (default) shows small diffs inline and larger ones only behind ctrl+o", (t) => {
|
|
196
198
|
t.after(() => setEditDiffMode("expanded"))
|
|
197
199
|
setEditDiffMode("expanded")
|
|
198
|
-
assert.
|
|
200
|
+
assert.equal(editRows(false).length, 4, "a 4-line diff is shown without ctrl+o")
|
|
201
|
+
assert.equal(editRows(false, longDiff(8)).length, 8, "8 lines is still small")
|
|
202
|
+
assert.deepEqual(editRows(false, longDiff(9)), [], "9 lines waits for ctrl+o")
|
|
203
|
+
assert.equal(editRows(true, longDiff(9)).length, 9)
|
|
199
204
|
const rows = editRows(true)
|
|
200
205
|
assert.equal(rows.length, 4)
|
|
201
206
|
assert.match(rows[0]!, /│ <context> 1 context$/)
|
|
@@ -227,7 +232,7 @@ test("editDiff=always shows the diff inline and editDiff=never only on request f
|
|
|
227
232
|
test("editDiff=always truncates a long inline diff and leaves the full diff to ctrl+o", (t) => {
|
|
228
233
|
t.after(() => setEditDiffMode("expanded"))
|
|
229
234
|
setEditDiffMode("always")
|
|
230
|
-
const long =
|
|
235
|
+
const long = longDiff(60)
|
|
231
236
|
const inline = editRows(false, long)
|
|
232
237
|
assert.equal(inline.length, 41)
|
|
233
238
|
assert.match(inline[40]!, /… 20 more lines \(ctrl\+o\)$/)
|
|
@@ -241,3 +246,31 @@ test("edit failures and results without a diff never render a diff block", (t) =
|
|
|
241
246
|
const failed = editRows(false, { content: [{ type: "text", text: "oldText not found" }] }, true)
|
|
242
247
|
assert.ok(failed.every((row) => !row.includes("<removed>")))
|
|
243
248
|
})
|
|
249
|
+
|
|
250
|
+
test("write rows show the written content on ctrl+o and nothing when collapsed", () => {
|
|
251
|
+
const renderers = compactRenderers("write")
|
|
252
|
+
const content = Array.from({ length: 450 }, (_, i) => `line ${i}`).join("\n")
|
|
253
|
+
const context = { args: { path: "a.ts", content }, toolCallId: "w1", invalidate() {}, state: {}, isPartial: false, isError: false, durationMs: 100 } as never
|
|
254
|
+
const result = { content: [{ type: "text", text: "Successfully wrote to a.ts" }] } as never
|
|
255
|
+
const collapsed = renderers.renderResult!(result, { expanded: false } as never, plainTheme, context).render(80)
|
|
256
|
+
assert.deepEqual(collapsed, [])
|
|
257
|
+
const expanded = renderers.renderResult!(result, { expanded: true } as never, plainTheme, context).render(80).map((l) => l.trimEnd())
|
|
258
|
+
assert.equal(expanded.length, 401)
|
|
259
|
+
assert.match(expanded[0]!, /│ line 0$/)
|
|
260
|
+
assert.match(expanded[400]!, /… 50 more lines$/)
|
|
261
|
+
const failed = { args: { path: "a.ts", content }, toolCallId: "w2", invalidate() {}, state: {}, isPartial: false, isError: true, durationMs: 100 } as never
|
|
262
|
+
const err = renderers.renderResult!({ content: [{ type: "text", text: "EACCES" }] } as never, { expanded: true } as never, plainTheme, failed).render(80)
|
|
263
|
+
assert.ok(err.every((l) => !l.includes("line 0")), "a failed write shows its error, not the content")
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
test("createRowRenderers keeps one live key per row across call and result", () => {
|
|
267
|
+
const renderers = compactRenderers("bash")
|
|
268
|
+
const state: Record<string, unknown> = {}
|
|
269
|
+
const call = { args: { command: "sleep 1" }, invalidate() {}, state, isPartial: true }
|
|
270
|
+
renderers.renderCall!({ command: "sleep 1" } as never, plainTheme, call as never)
|
|
271
|
+
assert.equal(state.liveId, "bash:sleep 1")
|
|
272
|
+
renderers.renderResult!({ content: [{ type: "text", text: "" }] } as never, { expanded: false } as never, plainTheme, { ...call, isPartial: false } as never)
|
|
273
|
+
assert.equal(state.liveId, "bash:sleep 1")
|
|
274
|
+
assert.equal((state as { running?: boolean }).running, false)
|
|
275
|
+
stopLiveWatch()
|
|
276
|
+
})
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import assert from "node:assert/strict"
|
|
2
|
+
import test from "node:test"
|
|
3
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"
|
|
4
|
+
import { installWorkingStatus } from "../src/working-status.js"
|
|
5
|
+
|
|
6
|
+
function fixture() {
|
|
7
|
+
const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => void>()
|
|
8
|
+
const messages: Array<string | undefined> = []
|
|
9
|
+
const ctx = { mode: "tui", ui: { setWorkingMessage: (text?: string) => { messages.push(text) } } } as unknown as ExtensionContext
|
|
10
|
+
const pi = { on: (name: string, handler: (event: unknown, ctx: ExtensionContext) => void) => { handlers.set(name, handler) } } as unknown as ExtensionAPI
|
|
11
|
+
const stop = installWorkingStatus(pi)
|
|
12
|
+
const emit = (name: string, event: unknown = {}) => handlers.get(name)!(event, ctx)
|
|
13
|
+
return { emit, messages, stop }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
test("working status walks thinking → tool → thinking → writing and clears when the agent settles", (t) => {
|
|
17
|
+
t.mock.timers.enable({ apis: ["setInterval", "Date"] })
|
|
18
|
+
const f = fixture()
|
|
19
|
+
f.emit("session_start")
|
|
20
|
+
f.emit("agent_start")
|
|
21
|
+
assert.match(f.messages.at(-1)!, /^Thinking · /)
|
|
22
|
+
f.emit("tool_execution_start", { toolCallId: "t1", toolName: "bash", args: { command: "ls -la" } })
|
|
23
|
+
assert.match(f.messages.at(-1)!, /^Running ls -la/)
|
|
24
|
+
f.emit("tool_execution_end", { toolCallId: "t1" })
|
|
25
|
+
assert.match(f.messages.at(-1)!, /^Thinking · /)
|
|
26
|
+
f.emit("message_update", { message: { role: "assistant", content: [] }, assistantMessageEvent: { type: "text_delta" } })
|
|
27
|
+
assert.match(f.messages.at(-1)!, /^Writing/)
|
|
28
|
+
f.emit("agent_settled")
|
|
29
|
+
assert.equal(f.messages.at(-1), undefined, "settling clears the message")
|
|
30
|
+
f.stop()
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test("events before session_start or after settle are ignored", () => {
|
|
34
|
+
const f = fixture()
|
|
35
|
+
f.emit("agent_start")
|
|
36
|
+
assert.deepEqual(f.messages, [])
|
|
37
|
+
f.emit("session_start")
|
|
38
|
+
f.emit("tool_execution_start", { toolCallId: "x", toolName: "read" })
|
|
39
|
+
assert.deepEqual(f.messages, [], "a tool without an active run does not paint")
|
|
40
|
+
f.stop()
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test("session_shutdown clears the working message while the context is still known", (t) => {
|
|
44
|
+
t.mock.timers.enable({ apis: ["setInterval", "Date"] })
|
|
45
|
+
const f = fixture()
|
|
46
|
+
f.emit("session_start")
|
|
47
|
+
f.emit("agent_start")
|
|
48
|
+
assert.ok(f.messages.at(-1))
|
|
49
|
+
f.emit("session_shutdown")
|
|
50
|
+
assert.equal(f.messages.at(-1), undefined, "shutdown must call setWorkingMessage() to clear")
|
|
51
|
+
f.emit("agent_start")
|
|
52
|
+
assert.equal(f.messages.at(-1), undefined, "no painting after shutdown dropped the context")
|
|
53
|
+
})
|
|
File without changes
|
|
File without changes
|