pi-spark 0.14.5 → 0.14.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.
- package/README.md +22 -4
- package/assets/screenshot-presets.png +0 -0
- package/package.json +1 -1
- package/src/features/pi/actions/name.ts +1 -11
- package/src/features/pi/actions/whoami.ts +4 -2
- package/src/features/presets/index.ts +2 -2
- package/src/features/presets/selector.ts +16 -9
- package/src/features/recap/idle.ts +1 -1
- package/src/utils/tool.ts +33 -21
package/README.md
CHANGED
|
@@ -6,6 +6,20 @@
|
|
|
6
6
|
|
|
7
7
|

|
|
8
8
|
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
Install from npm:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pi install npm:pi-spark
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Install from git:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pi install git:github.com/zlliang/pi-spark
|
|
21
|
+
```
|
|
22
|
+
|
|
9
23
|
## Features
|
|
10
24
|
|
|
11
25
|
### Compact TUI: editor, footer, and fullscreen
|
|
@@ -131,18 +145,22 @@ Each preset must set all three fields.
|
|
|
131
145
|
| --- | --- | --- |
|
|
132
146
|
| `provider` | string | Provider ID, e.g., `anthropic`. |
|
|
133
147
|
| `model` | string | Model ID, e.g., `claude-opus-4-8`. |
|
|
134
|
-
| `thinkingLevel` | `
|
|
148
|
+
| `thinkingLevel` | `ModelThinkingLevel` | Thinking level for the preset. |
|
|
135
149
|
|
|
136
150
|
#### `RecapConfig`
|
|
137
151
|
|
|
138
|
-
All fields are optional, including `thinkingLevel`.
|
|
152
|
+
All fields are optional, including `thinkingLevel`. If the recap model configuration is incomplete, pi-spark falls back to the session's main model.
|
|
139
153
|
|
|
140
154
|
| Field | Value | Description |
|
|
141
155
|
| --- | --- | --- |
|
|
142
|
-
| `idle` | number (ms) or duration string | How long the session must stay idle before a recap is generated. Accepts a millisecond number or a [vercel/ms](https://github.com/vercel/ms) string (e.g., `"
|
|
156
|
+
| `idle` | number (ms) or duration string | How long the session must stay idle before a recap is generated. Accepts a millisecond number or a [vercel/ms](https://github.com/vercel/ms) string (e.g., `"5m"`); minimum 5000 ms, defaults to 5 minutes. |
|
|
143
157
|
| `provider` | string | Provider ID for the recap model. |
|
|
144
158
|
| `model` | string | Model ID for the recap model. |
|
|
145
|
-
| `thinkingLevel` | `
|
|
159
|
+
| `thinkingLevel` | `ModelThinkingLevel` | Thinking level for the recap model. |
|
|
160
|
+
|
|
161
|
+
#### `ModelThinkingLevel`
|
|
162
|
+
|
|
163
|
+
Valid values: `off`, `minimal`, `low`, `medium`, `high`, `xhigh`.
|
|
146
164
|
|
|
147
165
|
### Turn off the features you don't like
|
|
148
166
|
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -22,23 +22,13 @@ export const nameAction = defineAction({
|
|
|
22
22
|
"(e.g., \"Refactor auth module\", \"Debug flaky CI pipeline\"). Do not use " +
|
|
23
23
|
"surrounding quotes, trailing punctuation, or generic prefixes like \"Chat about\".",
|
|
24
24
|
}),
|
|
25
|
-
reason: Type.Optional(Type.String({
|
|
26
|
-
maxLength: 240,
|
|
27
|
-
description:
|
|
28
|
-
"For \"name\": Explain briefly why the session was named or renamed, such as a long pasted " +
|
|
29
|
-
"prompt, an ambiguous first message, or a topic shift. Write one user-facing " +
|
|
30
|
-
"sentence (e.g., \"The focus shifted from debugging to README updates.\").",
|
|
31
|
-
})),
|
|
32
25
|
},
|
|
33
26
|
required: ["name"],
|
|
34
27
|
promptGuidelines: [
|
|
35
28
|
"Use the pi tool's \"name\" action to give the current session a concise, recognizable name, especially after a long, vague, or pasted opening prompt, or after a substantial shift in the conversation's focus.",
|
|
36
29
|
],
|
|
37
30
|
renderParams(args, theme) {
|
|
38
|
-
|
|
39
|
-
const reason = sanitizeText(args.reason ?? "");
|
|
40
|
-
|
|
41
|
-
return [reason ? name + theme.fg("muted", "\n\n" + reason) : name];
|
|
31
|
+
return [theme.fg("muted", sanitizeText(args.name ?? ""))];
|
|
42
32
|
},
|
|
43
33
|
renderResult() {
|
|
44
34
|
// "name" has no success UI; errors are rendered by the registry's fallback.
|
|
@@ -31,14 +31,16 @@ export const whoamiAction = defineAction({
|
|
|
31
31
|
return container;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
const formatLine = (label: string, value: string) => `${label.padEnd(7)} ${value}`;
|
|
35
|
+
|
|
34
36
|
if (details.sessionName) {
|
|
35
|
-
container.addChild(new Text(theme.fg("muted",
|
|
37
|
+
container.addChild(new Text(theme.fg("muted", formatLine("session", details.sessionName)), 0, 0));
|
|
36
38
|
}
|
|
37
39
|
|
|
38
40
|
if (details.model) {
|
|
39
41
|
const row = toModelRow(details.model, details.thinkingLevel);
|
|
40
42
|
const cells = [row.label, row.cost, row.context].join(" ");
|
|
41
|
-
container.addChild(new Text(theme.fg("muted",
|
|
43
|
+
container.addChild(new Text(theme.fg("muted", formatLine("model", cells)), 0, 0));
|
|
42
44
|
}
|
|
43
45
|
|
|
44
46
|
return container;
|
|
@@ -14,9 +14,9 @@ export function registerPresets(pi: ExtensionAPI): void {
|
|
|
14
14
|
type: "string",
|
|
15
15
|
});
|
|
16
16
|
|
|
17
|
-
pi.on("session_start", async (
|
|
17
|
+
pi.on("session_start", async (event, ctx) => {
|
|
18
18
|
const config = loadConfig(ctx).presets;
|
|
19
|
-
const presetFlag = pi.getFlag("preset");
|
|
19
|
+
const presetFlag = event.reason === "startup" ? pi.getFlag("preset") : undefined;
|
|
20
20
|
|
|
21
21
|
if (!config || Object.keys(config).length === 0) {
|
|
22
22
|
if (presetFlag) ctx.ui.notify("No presets defined in spark.json", "warning");
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { DynamicBorder } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { Container, SelectList, Spacer, Text } from "@earendil-works/pi-tui";
|
|
1
|
+
import { keyText, DynamicBorder } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Box, Container, SelectList, Spacer, Text } from "@earendil-works/pi-tui";
|
|
3
3
|
|
|
4
4
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
5
5
|
import type { PresetManager } from "./manager";
|
|
@@ -20,12 +20,11 @@ export async function showPresetSelector(ctx: ExtensionContext, presetManager: P
|
|
|
20
20
|
}));
|
|
21
21
|
|
|
22
22
|
const container = new Container();
|
|
23
|
-
|
|
24
23
|
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
24
|
+
|
|
25
|
+
const box = new Box(1, 1);
|
|
26
|
+
box.addChild(new Text(theme.bold(theme.fg("accent", "Select preset")), 0, 0));
|
|
27
|
+
box.addChild(new Spacer(1));
|
|
29
28
|
|
|
30
29
|
const selectList = new SelectList(items, 10, {
|
|
31
30
|
selectedPrefix: (text) => theme.fg("accent", text),
|
|
@@ -36,9 +35,17 @@ export async function showPresetSelector(ctx: ExtensionContext, presetManager: P
|
|
|
36
35
|
});
|
|
37
36
|
selectList.onSelect = (item) => done(item.value);
|
|
38
37
|
selectList.onCancel = () => done(null);
|
|
39
|
-
|
|
38
|
+
box.addChild(selectList);
|
|
39
|
+
box.addChild(new Spacer(1));
|
|
40
|
+
|
|
41
|
+
const keyHints = [
|
|
42
|
+
["↑↓", "navigate"],
|
|
43
|
+
[keyText("tui.select.confirm"), "select"],
|
|
44
|
+
[keyText("tui.select.cancel"), "cancel"],
|
|
45
|
+
] as const;
|
|
46
|
+
box.addChild(new Text(keyHints.map((item) => `${theme.fg("dim", item[0])} ${theme.fg("muted", item[1])}`).join(" "), 0, 0));
|
|
40
47
|
|
|
41
|
-
container.addChild(
|
|
48
|
+
container.addChild(box);
|
|
42
49
|
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
|
43
50
|
|
|
44
51
|
return {
|
|
@@ -31,7 +31,7 @@ export const idleTimeoutSchema = z
|
|
|
31
31
|
type IdleTimeout = z.infer<typeof idleTimeoutSchema>;
|
|
32
32
|
type IdleHash = string | number | boolean;
|
|
33
33
|
|
|
34
|
-
const DEFAULT_IDLE_MS =
|
|
34
|
+
const DEFAULT_IDLE_MS = 5 * 60 * 1000;
|
|
35
35
|
const POLL_MS = 1_000;
|
|
36
36
|
|
|
37
37
|
export class IdleListener<T> {
|
package/src/utils/tool.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { Type } from "typebox";
|
|
|
4
4
|
|
|
5
5
|
import { formatDuration, joinTextContent } from "./format";
|
|
6
6
|
|
|
7
|
-
import type { AgentToolResult, ExtensionAPI, ExtensionContext, Theme, ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import type { AgentToolResult, AgentToolUpdateCallback, ExtensionAPI, ExtensionContext, Theme, ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
8
8
|
import type { Component } from "@earendil-works/pi-tui";
|
|
9
9
|
import type { Static, TObject, TProperties, TSchema } from "typebox";
|
|
10
10
|
|
|
@@ -28,8 +28,9 @@ export interface Action<C, F extends TProperties = TProperties, D extends Action
|
|
|
28
28
|
showTiming?: boolean;
|
|
29
29
|
/** Styled segments shown after the `<tool> <action>` prefix. */
|
|
30
30
|
renderParams?: (args: Static<TObject<F>>, theme: Theme) => string[];
|
|
31
|
+
/** Only handles successful output; error output is handled centrally. */
|
|
31
32
|
renderResult?: NonNullable<ToolDefinition<TObject<F>, D>["renderResult"]>;
|
|
32
|
-
execute(args: Static<TObject<F>>, context: C, signal: AbortSignal | undefined): Promise<AgentToolResult<D>>;
|
|
33
|
+
execute(args: Static<TObject<F>>, context: C, signal: AbortSignal | undefined, onUpdate: AgentToolUpdateCallback<D> | undefined): Promise<AgentToolResult<D>>;
|
|
33
34
|
}
|
|
34
35
|
|
|
35
36
|
/** Identity helper bound to context `C`, so actions infer their field/details types while sharing one context shape. */
|
|
@@ -75,7 +76,6 @@ export function registerComposedTool<C>(pi: ExtensionAPI, config: ComposedToolCo
|
|
|
75
76
|
|
|
76
77
|
const activeTiming = new ActiveTiming();
|
|
77
78
|
const noTiming = new NoTiming();
|
|
78
|
-
// Resolve per-action timing from the stable call args, so error results (no details) still settle.
|
|
79
79
|
const timingFor = (action: string | undefined): Timing => (byName.get(action ?? "")?.showTiming ? activeTiming : noTiming);
|
|
80
80
|
|
|
81
81
|
const renderActionResult: NonNullable<ToolDefinition["renderResult"]> = (result, options, theme, context) => {
|
|
@@ -111,7 +111,7 @@ export function registerComposedTool<C>(pi: ExtensionAPI, config: ComposedToolCo
|
|
|
111
111
|
|
|
112
112
|
return timingFor(context.args.action).renderResult(inner, options, theme, context);
|
|
113
113
|
},
|
|
114
|
-
async execute(_toolCallId, params, signal,
|
|
114
|
+
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
115
115
|
const action = byName.get(params.action);
|
|
116
116
|
if (!action) throw new Error(`Unknown ${config.name} action "${params.action}"`);
|
|
117
117
|
|
|
@@ -121,7 +121,7 @@ export function registerComposedTool<C>(pi: ExtensionAPI, config: ComposedToolCo
|
|
|
121
121
|
}
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
-
return action.execute(params as
|
|
124
|
+
return action.execute(params as any, config.createContext(ctx), signal, onUpdate);
|
|
125
125
|
},
|
|
126
126
|
});
|
|
127
127
|
}
|
|
@@ -147,55 +147,67 @@ interface TimingState {
|
|
|
147
147
|
startedAt?: number;
|
|
148
148
|
endedAt?: number;
|
|
149
149
|
interval?: ReturnType<typeof setInterval>;
|
|
150
|
+
hasResult?: boolean;
|
|
150
151
|
}
|
|
151
152
|
|
|
152
153
|
class ActiveTiming implements Timing {
|
|
154
|
+
// No result yet: `renderCall` owns the live "Elapsed" ticker.
|
|
153
155
|
renderCall(inner: Component, ...[theme, context]: RenderCallTail): Component {
|
|
154
156
|
const state = context.state as TimingState;
|
|
157
|
+
|
|
155
158
|
if (context.executionStarted && state.startedAt === undefined) {
|
|
156
159
|
state.startedAt = Date.now();
|
|
157
160
|
delete state.endedAt;
|
|
161
|
+
delete state.hasResult;
|
|
158
162
|
}
|
|
159
163
|
|
|
160
|
-
//
|
|
161
|
-
if (!context.isPartial)
|
|
162
|
-
this.settle(state);
|
|
163
|
-
return inner;
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
// Still running, no result yet: show a live "Elapsed" line, ticking once a second.
|
|
167
|
-
if (state.startedAt === undefined) return inner;
|
|
164
|
+
// Not started, or final (`renderResult` owns the "Took" line): render nothing here.
|
|
165
|
+
if (state.startedAt === undefined || !context.isPartial) return inner;
|
|
168
166
|
|
|
169
167
|
state.interval ??= setInterval(() => context.invalidate(), 1000);
|
|
170
|
-
|
|
168
|
+
|
|
169
|
+
// `renderResult` runs later in the same render pass, so decide visibility at render time:
|
|
170
|
+
// hide once any result has arrived.
|
|
171
|
+
return this.withTimingLine(inner, "Elapsed", Date.now() - state.startedAt, theme, () => !state.hasResult);
|
|
171
172
|
}
|
|
172
173
|
|
|
174
|
+
// A result exists: `renderResult` owns the line — "Elapsed" while streaming, "Took" once final.
|
|
173
175
|
renderResult(inner: Component, ...[options, theme, context]: RenderResultTail): Component {
|
|
174
176
|
const state = context.state as TimingState;
|
|
175
|
-
|
|
177
|
+
state.hasResult = true;
|
|
178
|
+
|
|
179
|
+
const isRunning = options.isPartial && !context.isError;
|
|
180
|
+
if (!isRunning) this.settle(state);
|
|
176
181
|
if (state.startedAt === undefined) return inner;
|
|
177
182
|
|
|
178
|
-
const label = options.isPartial ? "Elapsed" : "Took";
|
|
179
183
|
const endTime = state.endedAt ?? Date.now();
|
|
180
|
-
return this.withTimingLine(inner,
|
|
184
|
+
return this.withTimingLine(inner, isRunning ? "Elapsed" : "Took", endTime - state.startedAt, theme);
|
|
181
185
|
}
|
|
182
186
|
|
|
183
187
|
private settle(state: TimingState): void {
|
|
184
|
-
if (state.startedAt !== undefined)
|
|
188
|
+
if (state.startedAt !== undefined) {
|
|
189
|
+
state.endedAt ??= Date.now();
|
|
190
|
+
}
|
|
191
|
+
|
|
185
192
|
if (state.interval) {
|
|
186
193
|
clearInterval(state.interval);
|
|
187
194
|
delete state.interval;
|
|
188
195
|
}
|
|
189
196
|
}
|
|
190
197
|
|
|
191
|
-
private withTimingLine(content: Component, label: string, ms: number, theme: Theme):
|
|
198
|
+
private withTimingLine(content: Component, label: string, ms: number, theme: Theme, visible?: () => boolean): Component {
|
|
192
199
|
const container = new Container();
|
|
193
200
|
container.addChild(content);
|
|
194
201
|
|
|
195
202
|
container.addChild(new Spacer(1));
|
|
196
|
-
container.addChild(new Text(
|
|
203
|
+
container.addChild(new Text(theme.fg("muted", `${label} ${formatDuration(ms)}`), 0, 0));
|
|
204
|
+
|
|
205
|
+
if (!visible) return container;
|
|
197
206
|
|
|
198
|
-
return
|
|
207
|
+
return {
|
|
208
|
+
invalidate: () => container.invalidate(),
|
|
209
|
+
render: (width) => (visible() ? container.render(width) : content.render(width)),
|
|
210
|
+
};
|
|
199
211
|
}
|
|
200
212
|
}
|
|
201
213
|
|