@huseynsnmz/pi-suite 0.2.2 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +8 -1
  3. package/package.json +6 -1
  4. package/packages/board/README.md +1 -1
  5. package/packages/commands/README.md +1 -1
  6. package/packages/commands/src/index.ts +22 -0
  7. package/packages/frame/README.md +8 -2
  8. package/packages/frame/docs/reference.md +1 -1
  9. package/packages/frame/src/index.ts +8 -3
  10. package/packages/frame/src/kit/settings/files.ts +8 -8
  11. package/packages/frame/src/kit/settings/store.ts +3 -1
  12. package/packages/frame/src/ui/editor.ts +64 -7
  13. package/packages/frame/src/ui/history-search.ts +21 -0
  14. package/packages/frame/src/ui/pi-entries.ts +2 -0
  15. package/packages/frame/src/ui/thinking.ts +34 -0
  16. package/packages/memory/README.md +1 -1
  17. package/packages/modes/README.md +2 -2
  18. package/packages/modes/src/index.ts +3 -6
  19. package/packages/router/README.md +29 -9
  20. package/packages/router/src/config.ts +28 -27
  21. package/packages/router/src/index.ts +61 -13
  22. package/packages/router/src/route.ts +16 -10
  23. package/packages/subscription/LICENSE +21 -0
  24. package/packages/subscription/README.md +60 -0
  25. package/packages/subscription/package.json +23 -0
  26. package/packages/subscription/src/anchors.ts +75 -0
  27. package/packages/subscription/src/billing.ts +39 -0
  28. package/packages/subscription/src/catalog.ts +310 -0
  29. package/packages/subscription/src/cc-sessions.ts +122 -0
  30. package/packages/subscription/src/config.ts +34 -0
  31. package/packages/subscription/src/convert.ts +161 -0
  32. package/packages/subscription/src/index.ts +69 -0
  33. package/packages/subscription/src/mcp.ts +78 -0
  34. package/packages/subscription/src/models.ts +65 -0
  35. package/packages/subscription/src/prompt.ts +57 -0
  36. package/packages/subscription/src/provider.ts +617 -0
  37. package/packages/subscription/src/session.ts +294 -0
  38. package/packages/subscription/src/util.ts +73 -0
  39. package/packages/web/README.md +5 -5
  40. package/packages/web/src/config.ts +13 -23
  41. package/packages/web/src/index.ts +11 -7
  42. package/packages/web/src/reader.ts +15 -4
  43. package/packages/web/src/validate.ts +2 -2
  44. package/src/index.ts +92 -10
  45. package/src/settings-ui.ts +63 -0
  46. package/src/settings.ts +116 -15
package/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.2.3]
4
+
5
+ ### Features
6
+
7
+ - Router: each role can have its own reasoning level, chosen in `/router` from what that role's model offers.
8
+ - Up and Down with one line typed go through past prompts that start with it. With nothing typed they also reach this folder's other sessions.
9
+ - With `hideThinkingBlock` on, reasoning shows while it's written and collapses when the message is done. `Ctrl+T` opens it.
10
+ - New part, off until you run `/suite on subscription`: Claude models on your Claude login, based on pi-claude-subscription. It asks before anything more is billed as extra usage. See its README for the limits and what Anthropic's terms say.
11
+
12
+ - One settings file, `~/.pi/agent/suite.json`, with a section for each part. `/suite` now opens each part's settings as well as turning it on or off. Old files move in the first time Pi starts, and stay as `.bak`.
13
+
14
+ ### Fixes
15
+
16
+ - `router/auto` is always listed in `/model`, and says to run `/router` until `plan` and `build` are set.
17
+ - `/clear` keeps the current model and reasoning level.
18
+
3
19
  ## [0.2.2]
4
20
 
5
21
  ### Fixes
package/README.md CHANGED
@@ -11,13 +11,20 @@ One package for [Pi](https://pi.dev): modes and permissions, a framed screen, a
11
11
  | `frame` | The start card, framed editor, tool boxes, footer and the agent's todo list | [README](packages/frame/README.md) |
12
12
  | `board` | A board for starting, watching and attaching to background sessions | [README](packages/board/README.md) |
13
13
  | `commands` | `/clear` and `/security-review` | [README](packages/commands/README.md) |
14
+ | `subscription` | Off until you turn it on. Claude models on your Claude login, asking before anything more is billed as extra usage | [README](packages/subscription/README.md) |
15
+
16
+ ## Settings
17
+
18
+ Run `/suite` to turn parts on or off and change their settings. They're all kept in `~/.pi/agent/suite.json`, with a section for each part, so you can also edit it by hand. Older files such as `router.json` and `web.json` move in the first time Pi starts, and stay beside it as `.bak`. If one can't be read it stays where it is, and you're told, so a broken file can't silently turn off a setting.
19
+
20
+ There's no setting per project, so a repository can't choose where your code goes. The only exception is the frame's look, in `.pi/extensions/pi-harness/settings.json`, once you trust the project. Pi's own files, and the settings of other packages, such as `pi-permission-system`, stay where they are.
14
21
 
15
22
  ## Install
16
23
 
17
24
  Requires Pi 1.5 or newer.
18
25
 
19
26
  ```bash
20
- pi install npm:@huseynsnmz/pi-suite@0.2.2
27
+ pi install npm:@huseynsnmz/pi-suite@0.2.3
21
28
  ```
22
29
 
23
30
  If you installed any of these separately, remove them first:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huseynsnmz/pi-suite",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "description": "One Pi package for all of it: modes and permissions, a framed screen, an agent board, memory, web tools with a separate page reader, and a model router. Turn off what you don't want with /suite.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -28,6 +28,10 @@
28
28
  "README.md",
29
29
  "CHANGELOG.md",
30
30
  "LICENSE",
31
+ "packages/subscription/src",
32
+ "packages/subscription/package.json",
33
+ "packages/subscription/README.md",
34
+ "packages/subscription/LICENSE",
31
35
  "packages/commands/src",
32
36
  "packages/commands/package.json",
33
37
  "packages/commands/README.md",
@@ -91,6 +95,7 @@
91
95
  "test:e2e": "python3 test/e2e/run.py"
92
96
  },
93
97
  "dependencies": {
98
+ "@anthropic-ai/claude-agent-sdk": "0.3.308",
94
99
  "@xterm/headless": "6.0.0",
95
100
  "node-pty": "1.1.0",
96
101
  "@mozilla/readability": "0.6.0",
@@ -26,7 +26,7 @@ Requires Pi 1.5 or newer.
26
26
  1. Install it:
27
27
 
28
28
  ```bash
29
- pi install npm:@huseynsnmz/pi-suite@0.2.2
29
+ pi install npm:@huseynsnmz/pi-suite@0.2.3
30
30
  ```
31
31
 
32
32
  2. Restart Pi, and press `Left` twice on an empty prompt, or run `/agent-board`.
@@ -4,7 +4,7 @@ Two commands for Pi. Part of [pi-suite](../../README.md); turn them off with `/s
4
4
 
5
5
  ## /clear
6
6
 
7
- Clears the conversation and starts a new session, as `/new` does. If the agent is working, it stops first. The old session is kept, and `/resume` brings it back.
7
+ Clears the conversation and starts a new session, keeping the model and reasoning level you have selected. `/new` goes back to your saved default instead. If the agent is working, it stops first. The old session is kept, and `/resume` brings it back.
8
8
 
9
9
  ## /security-review
10
10
 
@@ -5,6 +5,14 @@ import { changesToReview, type GitRun, reviewPrompt } from "./security-review.ts
5
5
 
6
6
  const REVIEW_MESSAGE = "security-review";
7
7
 
8
+ /** The model /clear hands to the session it starts. Pi's own new session would take the saved default. */
9
+ const KEPT = Symbol.for("pi-suite:clear-keeps");
10
+ interface Kept {
11
+ provider: string;
12
+ id: string;
13
+ thinking: ReturnType<ExtensionAPI["getThinkingLevel"]>;
14
+ }
15
+
8
16
  export default function piCommands(pi: ExtensionAPI): void {
9
17
  pi.registerCommand("clear", {
10
18
  description: "Clear the conversation and start a new session",
@@ -13,10 +21,24 @@ export default function piCommands(pi: ExtensionAPI): void {
13
21
  ctx.abort();
14
22
  await ctx.waitForIdle();
15
23
  }
24
+ const store = globalThis as Record<symbol, Kept | undefined>;
25
+ if (ctx.model) store[KEPT] = { provider: ctx.model.provider, id: ctx.model.id, thinking: pi.getThinkingLevel() };
16
26
  await ctx.newSession();
27
+ delete store[KEPT]; // Still here only if it was cancelled.
17
28
  },
18
29
  });
19
30
 
31
+ // Runs in the new session, where this extension is a new instance, so what /clear kept can't live in it.
32
+ pi.on("session_start", async (event, ctx) => {
33
+ const store = globalThis as Record<symbol, Kept | undefined>;
34
+ const kept = store[KEPT];
35
+ delete store[KEPT];
36
+ if (event.reason !== "new" || kept === undefined) return;
37
+ const model = ctx.modelRegistry.find(kept.provider, kept.id);
38
+ if (model !== undefined) await pi.setModel(model);
39
+ pi.setThinkingLevel(kept.thinking);
40
+ });
41
+
20
42
  // The diff goes to the model, and only this one line goes on screen.
21
43
  pi.registerMessageRenderer<{ summary: string }>(REVIEW_MESSAGE, (message, _options, theme) =>
22
44
  new Text(theme.fg("muted", message.details?.summary ?? "Security review"), 1, 0),
@@ -19,7 +19,7 @@ Requires Pi 1.5 or newer.
19
19
  2. Install it:
20
20
 
21
21
  ```bash
22
- pi install npm:@huseynsnmz/pi-suite@0.2.2
22
+ pi install npm:@huseynsnmz/pi-suite@0.2.3
23
23
  ```
24
24
 
25
25
  3. Restart Pi. The start card says `π-frame`.
@@ -44,6 +44,8 @@ Some pieces, such as rooms, boards, peers and the remote page, appear only with
44
44
  |---|---|
45
45
  | `Ctrl+C` | Stops a run and says `Aborted`. With nothing running, it clears the editor and says to press it again within 2 seconds to exit |
46
46
  | `Ctrl+R` | Search what you've typed, here and in this folder's other sessions. `Ctrl+R` again goes further back, `Enter` sends, `Esc` cancels, any other key keeps the match to edit. It replaces Pi's Rename on this key; `/name` still renames |
47
+ | `Up` / `Down` | With nothing typed, browse past prompts, including this folder's other sessions. With one line typed, go through prompts that start with it, as in a shell |
48
+ | `Ctrl+T` | Open or collapse finished reasoning |
47
49
  | `Alt+O` | Scroll through the session's tool output, one call at a time |
48
50
  | `Ctrl+O` | Expand every call in the transcript |
49
51
  | `/todos` | Show the agent's todo list. `/todos clear` empties it and tells the agent |
@@ -78,7 +80,7 @@ More detail is in [How it works](docs/how-it-works.md) and the [reference](docs/
78
80
  - **`gh` and `glab` still run in their repositories,** and they call `git` for reads such as the remote list.
79
81
  - **`glab` needs a host with `gitlab` in its name.** It starts for that remote, and if a token is set in the environment (`GITLAB_TOKEN`), only for `gitlab.com` or `GITLAB_HOST`, because a repository picks its own remote.
80
82
  - **The `git status` protections cover the ways to run code that I know of.** New Git options could add others.
81
- - **The parts of `@adeildo/pi-kit` 6.0.0 that frame uses are copied into `src/kit`,** with its license, so upstream fixes have to be copied in by hand. Its settings stay in `~/.pi/agent/extensions/pi-harness/settings.json`.
83
+ - **The parts of `@adeildo/pi-kit` 6.0.0 that frame uses are copied into `src/kit`,** with its license, so upstream fixes have to be copied in by hand. Its settings stay in `~/.pi/agent/suite.json`.
82
84
  - **Another session's view isn't fully covered.** Opening one needs other pi-harness packages, and that view hasn't been tested.
83
85
 
84
86
  ## Development
@@ -103,3 +105,7 @@ MIT. See [LICENSE](LICENSE), which keeps the original copyright notice.
103
105
  - [pi-look and pi-harness](https://github.com/felipeadeildo/pi-harness) by [adeildo](https://adeildo.dev)
104
106
  - The framed editor comes from [pi-open-tui](https://github.com/OldSuns/pi-open-tui), by way of pi-look.
105
107
  - The segments with priorities come from [oh-my-pi](https://github.com/can1357/oh-my-pi), by way of pi-look.
108
+
109
+ ## Reasoning
110
+
111
+ Set `"hideThinkingBlock": true` in `~/.pi/agent/settings.json`, or press `Ctrl+T` until reasoning is collapsed. It then shows while the model is writing it and collapses to one line once the message is done. `Ctrl+T` opens it again, or click it to open just that one. `Ctrl+T` is Pi's own setting, so it applies to every message and stays set in later sessions.
@@ -27,7 +27,7 @@ Syntax tokens are borrowed on purpose: a theme spends its hues there, so they gi
27
27
 
28
28
  ## Settings
29
29
 
30
- They live in the file `@adeildo/pi-kit` keeps, `~/.pi/agent/extensions/pi-harness/settings.json`, under `frame.`.
30
+ They live under `frame` in `~/.pi/agent/suite.json`.
31
31
 
32
32
  | Key | Default | Does |
33
33
  | ------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------- |
@@ -1,4 +1,4 @@
1
- import { dirname, join } from "node:path";
1
+ import { join } from "node:path";
2
2
 
3
3
  import {
4
4
  ACCOUNT,
@@ -23,7 +23,7 @@ import type {
23
23
  ExtensionContext,
24
24
  ReadonlyFooterDataProvider,
25
25
  } from "@earendil-works/pi-coding-agent";
26
- import { keyText, parseSkillBlock } from "@earendil-works/pi-coding-agent";
26
+ import { getAgentDir, keyText, parseSkillBlock } from "@earendil-works/pi-coding-agent";
27
27
  import { Key, type KeyId, type TUI } from "@earendil-works/pi-tui";
28
28
 
29
29
  import { type GitState, readGit } from "./data/git.ts";
@@ -157,7 +157,7 @@ export const frameFeature = defineFeature({
157
157
 
158
158
  for (const entry of FRAME_SETTINGS) entry.listen(scope, repaint);
159
159
  frame.listen(scope, () => live.ctx && installEditor(live.ctx, screen, live, scope));
160
- const visitsFile = join(dirname(scope.settings.globalPath), "frame-visits.json");
160
+ const visitsFile = join(getAgentDir(), "extensions", "pi-harness", "frame-visits.json");
161
161
  let visited = false;
162
162
  let lastVisit: LastVisit | undefined;
163
163
 
@@ -527,6 +527,9 @@ function pressCtrlC(live: Live, clearEditor: () => void): void {
527
527
  live.repaint?.();
528
528
  }
529
529
 
530
+ /** Everything collapsed has finished, because live reasoning stays open. */
531
+ const THOUGHT_LABEL = "Thought · ctrl+t to expand";
532
+
530
533
  function installEditor(
531
534
  ctx: ExtensionContext,
532
535
  screen: Screen,
@@ -535,9 +538,11 @@ function installEditor(
535
538
  ): void {
536
539
  if (ctx.mode !== "tui") return;
537
540
  if (frame.get(scope) === "off") {
541
+ ctx.ui.setHiddenThinkingLabel(undefined);
538
542
  ctx.ui.setEditorComponent(undefined);
539
543
  return;
540
544
  }
545
+ ctx.ui.setHiddenThinkingLabel(THOUGHT_LABEL);
541
546
  ctx.ui.setEditorComponent((tui, theme, keybindings) => {
542
547
  live.tui = tui;
543
548
  applyCursor(tui, cursor);
@@ -1,5 +1,5 @@
1
1
  // Every app shares the settings files, so a write keeps every key it does not know.
2
- import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
+ import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, statSync, writeFileSync } from "node:fs";
3
3
  import { dirname, join } from "node:path";
4
4
 
5
5
  import { CONFIG_DIR_NAME, getAgentDir } from "@earendil-works/pi-coding-agent";
@@ -14,7 +14,7 @@ export interface SettingsFile {
14
14
  }
15
15
 
16
16
  export function globalSettingsPath(): string {
17
- return join(getAgentDir(), "extensions", "pi-harness", "settings.json");
17
+ return join(getAgentDir(), "suite.json");
18
18
  }
19
19
 
20
20
  export function projectSettingsPath(cwd: string): string {
@@ -40,12 +40,12 @@ export function writeSettingsFile(
40
40
  options: { mode?: number } = {},
41
41
  ): void {
42
42
  mkdirSync(dirname(path), { recursive: true });
43
- const temporary = `${path}.${process.pid}.tmp`;
44
- writeFileSync(temporary, `${JSON.stringify(data, null, "\t")}\n`, {
45
- encoding: "utf8",
46
- mode: options.mode,
47
- });
48
- renameSync(temporary, path);
43
+ // A linked file stays linked, and a private one stays private. Other parts' settings share it.
44
+ const target = existsSync(path) ? realpathSync(path) : path;
45
+ const mode = options.mode ?? (existsSync(target) ? statSync(target).mode & 0o777 : undefined);
46
+ const temporary = `${target}.${process.pid}.tmp`;
47
+ writeFileSync(temporary, `${JSON.stringify(data, null, "\t")}\n`, { encoding: "utf8", mode });
48
+ renameSync(temporary, target);
49
49
  }
50
50
 
51
51
  export function lookup(data: SettingsData, id: string): unknown {
@@ -135,7 +135,9 @@ export class SettingsStore {
135
135
  }
136
136
 
137
137
  #edit(path: string, change: (data: SettingsData) => void): string | undefined {
138
- const { data } = readSettingsFile(path);
138
+ const { data, warnings } = readSettingsFile(path);
139
+ // Reading a broken file gives nothing, and writing that would wipe what else it holds.
140
+ if (warnings.length > 0) return warnings[0];
139
141
  change(data);
140
142
  try {
141
143
  writeSettingsFile(path, data);
@@ -15,7 +15,15 @@ import { BOXES, border } from "../render/frame.ts";
15
15
  import { paintLead, promptLead } from "../render/lead.ts";
16
16
  import { SETTINGS_KEY, SETTINGS_SHORTCUT_INPUT } from "../render/keys.ts";
17
17
  import type { CursorStyle } from "../settings.ts";
18
- import { findPrompt, type HistorySearch, shownText, startSearch } from "./history-search.ts";
18
+ import {
19
+ findPrompt,
20
+ type HistorySearch,
21
+ type PrefixSearch,
22
+ shownText,
23
+ startPrefix,
24
+ startSearch,
25
+ stepPrefix,
26
+ } from "./history-search.ts";
19
27
  import { type Screen, type SlotName, slotPieces } from "./screen.ts";
20
28
 
21
29
  type WorkingIndicator = Parameters<CustomEditor["setWorkingStatusIndicator"]>[0];
@@ -44,6 +52,8 @@ export class FrameEditor extends CustomEditor {
44
52
  #hiddenBelow = 0;
45
53
  #inset = 0;
46
54
  #search: HistorySearch | undefined;
55
+ #prefix: PrefixSearch | undefined;
56
+ #seeded = false;
47
57
 
48
58
  constructor(
49
59
  tui: TUI,
@@ -77,13 +87,9 @@ export class FrameEditor extends CustomEditor {
77
87
  this.#searchKey(this.#search, data);
78
88
  return;
79
89
  }
90
+ if (this.#prefixKey(data)) return;
80
91
  if (matchesKey(data, SEARCH_KEY) && !this.isShowingAutocomplete()) {
81
- // What Up browses, then what was typed in this folder's other sessions.
82
- const own: unknown = Reflect.get(this, "history");
83
- this.#search = startSearch(this.getText(), [
84
- ...(Array.isArray(own) ? (own as string[]) : []),
85
- ...this.#screen.pastPrompts(),
86
- ]);
92
+ this.#search = startSearch(this.getText(), this.#history());
87
93
  return;
88
94
  }
89
95
  if (this.#keybindings.matches(data, "app.clear") && !this.isShowingAutocomplete()) {
@@ -101,6 +107,51 @@ export class FrameEditor extends CustomEditor {
101
107
  if (skillQueryAt(this.getLines(), line, col) !== undefined) openAutocomplete(this);
102
108
  }
103
109
 
110
+ /** What Up browses, then what was typed in this folder's other sessions. Read once, on first use. */
111
+ #history(): readonly string[] {
112
+ const own: unknown = Reflect.get(this, "history");
113
+ if (!Array.isArray(own)) return this.#screen.pastPrompts();
114
+ const entries = own as string[];
115
+ if (!this.#seeded) {
116
+ this.#seeded = true;
117
+ for (const prompt of this.#screen.pastPrompts()) {
118
+ if (!entries.includes(prompt)) entries.push(prompt);
119
+ }
120
+ }
121
+ return entries;
122
+ }
123
+
124
+ /**
125
+ * Up with one line typed looks back through prompts that start with it, and Down comes forward
126
+ * again. Any other key keeps what's showing. An empty prompt is left to pi's own browsing.
127
+ */
128
+ #prefixKey(data: string): boolean {
129
+ const up = this.#keybindings.matches(data, "tui.editor.cursorUp");
130
+ const down = this.#keybindings.matches(data, "tui.editor.cursorDown");
131
+ if (!up && !down) {
132
+ this.#prefix = undefined;
133
+ return false;
134
+ }
135
+ if (this.isShowingAutocomplete()) return false;
136
+ if (this.#prefix === undefined) {
137
+ const typed = this.getText();
138
+ const entries = this.#history();
139
+ const browsing = Reflect.get(this, "historyIndex") !== -1;
140
+ if (down || typed === "" || typed.includes("\n") || browsing || !onFirstVisualLine(this)) return false;
141
+ const search = startPrefix(typed, entries);
142
+ // With nothing to go through, the arrows stay pi's.
143
+ if (search.matches.length === 0) return false;
144
+ this.#prefix = search;
145
+ }
146
+ const text = stepPrefix(this.#prefix, up ? 1 : -1);
147
+ if (text === undefined && down) {
148
+ this.#prefix = undefined;
149
+ return false;
150
+ }
151
+ if (text !== undefined) this.setText(text);
152
+ return true;
153
+ }
154
+
104
155
  /** Enter sends the match, Esc goes back, and any other key keeps the match to edit. */
105
156
  #searchKey(search: HistorySearch, data: string): void {
106
157
  if (matchesKey(data, SEARCH_KEY)) {
@@ -248,6 +299,12 @@ export class FrameEditor extends CustomEditor {
248
299
  }
249
300
  }
250
301
 
302
+ /** The method is private. If a pi release renames it, Up just moves the cursor as before. */
303
+ function onFirstVisualLine(editor: CustomEditor): boolean {
304
+ const check: unknown = Reflect.get(editor, "isOnFirstVisualLine");
305
+ return typeof check === "function" && check.call(editor) === true;
306
+ }
307
+
251
308
  /** The method is private; nothing opens if a pi release renames it. */
252
309
  function openAutocomplete(editor: CustomEditor): void {
253
310
  const open: unknown = Reflect.get(editor, "tryTriggerAutocomplete");
@@ -21,3 +21,24 @@ export function findPrompt(search: HistorySearch, from: number): number {
21
21
  export function shownText(search: HistorySearch): string {
22
22
  return search.at < 0 ? search.draft : (search.entries[search.at] as string);
23
23
  }
24
+
25
+ /** Up with text typed: older prompts that start with it, as in a shell. */
26
+ export interface PrefixSearch {
27
+ typed: string;
28
+ matches: readonly string[];
29
+ at: number;
30
+ }
31
+
32
+ export function startPrefix(typed: string, entries: readonly string[]): PrefixSearch {
33
+ const wanted = typed.toLowerCase();
34
+ const matches = [...new Set(entries)].filter((entry) => entry !== typed && entry.toLowerCase().startsWith(wanted));
35
+ return { typed, matches, at: -1 };
36
+ }
37
+
38
+ /** One step older (1) or newer (-1), ending back on what was typed. Undefined past either end. */
39
+ export function stepPrefix(search: PrefixSearch, step: 1 | -1): string | undefined {
40
+ const next = search.at + step;
41
+ if (next < -1 || next >= search.matches.length) return undefined;
42
+ search.at = next;
43
+ return next < 0 ? search.typed : search.matches[next];
44
+ }
@@ -1,5 +1,6 @@
1
1
  // Pi's own transcript entries in the harness frame instead of a background. Pi has no hook for
2
2
  // drawing them, so this wraps each component's render.
3
+ import { showThinkingWhileLive } from "./thinking.ts";
3
4
  import { CHANGES_CUT, CHANGES_X, entryFrame, type EntryFrame, GLYPH } from "../kit/index.ts";
4
5
  import {
5
6
  BashExecutionComponent,
@@ -63,6 +64,7 @@ export function framePiEntries(source: EntryPaint): void {
63
64
  paint = source;
64
65
  if (installed) return;
65
66
  installed = true;
67
+ showThinkingWhileLive(() => paint() !== undefined);
66
68
  wrap(UserMessageComponent, (self, look) =>
67
69
  look.you ? youFrame(look, fromOf(self, look), sentInOf(self, look)) : undefined,
68
70
  );
@@ -0,0 +1,34 @@
1
+ import { AssistantMessageComponent } from "@earendil-works/pi-coding-agent";
2
+
3
+ /** Private in pi. If a release renames them, reasoning just follows pi's setting again. */
4
+ interface Message {
5
+ updateContent(message: { content: readonly { type: string }[] }, isStreaming?: boolean): void;
6
+ isStreaming?: boolean;
7
+ thinkingVisibilityOverrides?: Map<number, boolean>;
8
+ }
9
+
10
+ const live = new WeakSet<object>();
11
+ let isActive: () => boolean = () => false;
12
+ let installed = false;
13
+
14
+ /**
15
+ * With pi's setting that collapses reasoning on, it still shows while it's being written, and
16
+ * collapses once the message is done. Ctrl+T or a click opens it again.
17
+ */
18
+ export function showThinkingWhileLive(active: () => boolean): void {
19
+ isActive = active;
20
+ if (installed) return;
21
+ installed = true;
22
+ const prototype = AssistantMessageComponent.prototype as unknown as Message;
23
+ const own = prototype.updateContent;
24
+ prototype.updateContent = function updateContent(this: Message, message, isStreaming = this.isStreaming) {
25
+ const overrides = this.thinkingVisibilityOverrides;
26
+ if (overrides !== undefined && isActive() && isStreaming === true) {
27
+ live.add(this);
28
+ message.content.forEach((_part, run) => overrides.set(run, false));
29
+ } else if (overrides !== undefined && live.delete(this)) {
30
+ overrides.clear();
31
+ }
32
+ own.call(this, message, isStreaming);
33
+ };
34
+ }
@@ -9,7 +9,7 @@ Long-term memory for [Pi](https://pi.dev). It reviews your past conversations, k
9
9
  Requires Pi 1.5 or newer, on Node 22.18 or newer.
10
10
 
11
11
  ```bash
12
- pi install npm:@huseynsnmz/pi-suite@0.2.2
12
+ pi install npm:@huseynsnmz/pi-suite@0.2.3
13
13
  ```
14
14
 
15
15
  ## Use
@@ -23,7 +23,7 @@ Requires Pi 1.5 or newer.
23
23
  1. Install it:
24
24
 
25
25
  ```bash
26
- pi install npm:@huseynsnmz/pi-suite@0.2.2
26
+ pi install npm:@huseynsnmz/pi-suite@0.2.3
27
27
  ```
28
28
 
29
29
  2. Move pi-suite above `@gotgenes/pi-permission-system`, because `pi install` adds packages last. Otherwise, in `plan` and `read-only` you may be asked to approve an action that is then blocked anyway. It stays blocked either way, and the extension warns you at startup. Skip this step if you don't use that package.
@@ -55,7 +55,7 @@ To install from git instead, use `pi install git:github.com/huseynsnmz/pi-suite@
55
55
  | `/mode default` | Pick the mode new sessions start in |
56
56
  | `/mode default plan` | Set it directly |
57
57
 
58
- The default is saved as `defaultMode` in `~/.pi/agent/extensions/pi-modes/config.json`. A resumed session keeps the mode it was in. `yolo` can't be the default, because it has to be asked for each time.
58
+ The default is saved as `defaultMode` under `modes` in `~/.pi/agent/suite.json`, and you can also set it in `/suite`. A resumed session keeps the mode it was in. `yolo` can't be the default, because it has to be asked for each time.
59
59
 
60
60
  `Shift+Tab` normally cycles the thinking level, and this extension takes it over. To keep that action on another key, add this to `~/.pi/agent/keybindings.json`:
61
61
 
@@ -10,6 +10,7 @@ import { registerInterview } from "./interview.ts";
10
10
  import { registerReviewTools } from "./review/tools.ts";
11
11
  import { aliasDefinitions, DENY_OFF_PROMPT, withAliases, withoutAliases } from "./deny-off.ts";
12
12
  import { clearGate, hasPermissionSystem, installJudgeGate, JUDGE_NAMES, judgeGated, registerYoloLink, setGateMode, YOLO_LINK } from "./judge-gate.ts";
13
+ import { readSection, writeSection } from "../../../src/settings.ts";
13
14
  import { decideToolCall, displayCommand, fromExpectedPackage, notReadOnly, isMode, scriptedChange, loadsAfterPermissionSystem, type Mode, MODES, modePrompt, nextMode, resumedMode, startingMode, SUBMIT_PLAN_TOOL } from "./modes.ts";
14
15
 
15
16
  const STATUS_KEY = "pi-modes";
@@ -87,20 +88,16 @@ function packagesLoadThisFirst(): boolean {
87
88
  }
88
89
  }
89
90
 
90
- const configPath = () => join(getAgentDir(), "extensions", "pi-modes", "config.json");
91
-
92
91
  function readConfig(): Record<string, unknown> {
93
92
  try {
94
- const config: unknown = JSON.parse(readFileSync(configPath(), "utf8"));
95
- return typeof config === "object" && config !== null ? (config as Record<string, unknown>) : {};
93
+ return readSection(getAgentDir(), "modes");
96
94
  } catch {
97
95
  return {};
98
96
  }
99
97
  }
100
98
 
101
99
  function saveDefaultMode(mode: Mode): void {
102
- mkdirSync(dirname(configPath()), { recursive: true });
103
- writeFileSync(configPath(), `${JSON.stringify({ ...readConfig(), defaultMode: mode }, null, 2)}\n`);
100
+ writeSection(getAgentDir(), "modes", { ...readConfig(), defaultMode: mode });
104
101
  }
105
102
 
106
103
  function restoredMode(ctx: ExtensionContext): Mode {
@@ -9,10 +9,12 @@ Adds `router/auto` to [Pi](https://pi.dev): one choice in `/model` that uses a s
9
9
  Requires Pi 1.5 or newer.
10
10
 
11
11
  ```bash
12
- pi install npm:@huseynsnmz/pi-suite@0.2.2
12
+ pi install npm:@huseynsnmz/pi-suite@0.2.3
13
13
  ```
14
14
 
15
- Log in to the providers you want with `/login`, run `/router` to choose a model for each role, then pick `router/auto` in `/model`.
15
+ Log in to the providers you want with `/login`, run `/router` to choose a model for each role, then pick `router/auto` in `/model`. It's always listed there, and until `plan` and `build` are set it stops with a message telling you to run `/router`.
16
+
17
+ In `/model`, `Enter` selects a model for this session and `Ctrl+S` saves it as your default for new sessions.
16
18
 
17
19
  ## Roles
18
20
 
@@ -25,17 +27,35 @@ Log in to the providers you want with `/login`, run `/router` to choose a model
25
27
  | `subagent` | Runs subagents that use `router/auto` | Same as `build` |
26
28
  | `fallback` | Takes over when a provider is overloaded or rate limited | Retries on the same model |
27
29
 
28
- `/router` saves them in `~/.pi/agent/router.json`, apart from `judge`, which goes in the judge's own config:
30
+ `/router` saves them under `router` in `~/.pi/agent/suite.json`, apart from `judge`, which goes in the judge's own config. `/suite` opens the same screen.
31
+
32
+ ```json
33
+ {
34
+ "router": {
35
+ "plan": "anthropic/claude-opus-5-5",
36
+ "build": "anthropic/claude-haiku-5-5",
37
+ "fallback": "openai/gpt-5.6-luna"
38
+ }
39
+ }
40
+ ```
41
+
42
+ There's no setting per project, so a repository can't choose where your code gets sent.
43
+
44
+ ## Reasoning per role
45
+
46
+ After you choose a model for a role, `/router` asks how much reasoning it should use, offering only the levels that model has. It's saved next to the roles:
29
47
 
30
48
  ```json
31
49
  {
32
- "plan": "anthropic/claude-opus-5-5",
33
- "build": "anthropic/claude-haiku-5-5",
34
- "fallback": "openai/gpt-5.6-luna"
50
+ "router": {
51
+ "plan": "anthropic/claude-opus-5-5",
52
+ "build": "anthropic/claude-haiku-5-5",
53
+ "thinking": { "plan": "high", "build": "low" }
54
+ }
35
55
  }
36
56
  ```
37
57
 
38
- There's no setting per project, so a repository can't choose where your code gets sent. The thinking level you pick with `/effort` applies to every role, and Pi lowers it for a model that can't do it.
58
+ A role with no level of its own uses the one you pick with `/effort`. Changing a role's model clears its level, because the new model may not have it. A retry keeps the level of the request that failed. `reader` and `subagent` with no model of their own use `build`'s level. `judge` has no level here, as it's set in the judge's own config.
39
59
 
40
60
  ## How it decides
41
61
 
@@ -50,7 +70,7 @@ There's no setting per project, so a repository can't choose where your code get
50
70
  - Moving from `plan` to `build` happens once in a session. It loses the prompt cache one time, as any change of model does.
51
71
  - The session stays on `build` after that. For a hard follow-up, switch to `plan` mode, or start a new session.
52
72
  - If a request fails because the provider is overloaded, it moves to `fallback` for the rest of that turn. The next message tries the first choice again. Any other failure retries on the same model.
53
- - A role whose model is missing or has no login stops that request with an error naming it, and so does a broken `router.json`. The router doesn't pick another model for you.
73
+ - A role whose model is missing or has no login stops that request with an error naming it, and so do broken router settings. The router doesn't pick another model for you.
54
74
  - Leaving `plan` mode counts as finishing planning, whether you approved a plan or changed mode.
55
75
  - The roles only apply while `router/auto` is selected. On any other model, nothing goes to them, and pages are read as if the router weren't there.
56
76
  - `/suite off router` moves the session to the model that last answered. If `router/auto` is your saved default, later sessions use another available model, as Pi does for any model that no longer exists.
@@ -61,7 +81,7 @@ The phase follows the session tree, so a branch keeps its own.
61
81
 
62
82
  **`modes`:** tells the router the mode. With it off, the router only goes by the first edit.
63
83
 
64
- **`web`:** your `readerModel` in `web.json` still wins over the `reader` role.
84
+ **`web`:** your `readerModel` in the web settings still wins over the `reader` role.
65
85
 
66
86
  **`frame`:** shows who answered, such as `Auto → Haiku`. Pi's own footer already does.
67
87