@oh-my-pi/pi-tui 18.0.9 → 18.0.10
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 +8 -0
- package/dist/types/components/composer/band.d.ts +2 -0
- package/dist/types/components/composer/index.d.ts +1 -0
- package/dist/types/components/composer/types.d.ts +3 -3
- package/dist/types/components/loader.d.ts +6 -0
- package/package.json +3 -3
- package/src/components/composer/band.ts +42 -0
- package/src/components/composer/index.ts +1 -0
- package/src/components/composer/registry.ts +2 -0
- package/src/components/composer/types.ts +12 -3
- package/src/components/editor.ts +28 -10
- package/src/components/loader.ts +19 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [18.0.10] - 2026-08-28
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Press the Right Arrow at the end of a line to accept autocomplete suggestions, inline ghost-text completions, and spelling corrections, just like Tab.
|
|
10
|
+
- Added a smooth brand-color transition and an elapsed turn timer to the status line while the agent is working.
|
|
11
|
+
- Added a full-width “band” composer style for flush status lines.
|
|
12
|
+
|
|
5
13
|
## [18.0.9] - 2026-08-28
|
|
6
14
|
|
|
7
15
|
### Added
|
|
@@ -10,7 +10,7 @@ import type { SymbolTheme } from "../../symbols.js";
|
|
|
10
10
|
/** Box-drawing glyph set used for composer chrome (the theme's `boxRound`). */
|
|
11
11
|
export type ComposerBox = SymbolTheme["boxRound"];
|
|
12
12
|
/** Built-in composer shape identifiers shipped by pi-tui. */
|
|
13
|
-
export declare const BUILTIN_EDITOR_BORDER_STYLES: readonly ["box", "claude", "pi", "borderless", "rule", "field", "rail"];
|
|
13
|
+
export declare const BUILTIN_EDITOR_BORDER_STYLES: readonly ["box", "band", "claude", "pi", "borderless", "rule", "field", "rail"];
|
|
14
14
|
/** Identifier for a built-in composer shape. */
|
|
15
15
|
export type BuiltinEditorBorderStyle = (typeof BUILTIN_EDITOR_BORDER_STYLES)[number];
|
|
16
16
|
/** Composer shape identifier; extensions may register additional strings. */
|
|
@@ -66,8 +66,8 @@ export interface ComposerStyle {
|
|
|
66
66
|
/** Rows consumed by top+bottom chrome (drives maxHeight budgeting). */
|
|
67
67
|
readonly verticalChrome: 0 | 1 | 2;
|
|
68
68
|
/** Where the host should attach the status bar: embedded in the top border,
|
|
69
|
-
* docked onto a top rule, or detached into a standalone bottom bar. */
|
|
70
|
-
readonly statusAttachment: "top-border" | "top-rule-chip" | "none";
|
|
69
|
+
* rendered as a flush soft-capped band above the input, docked onto a top rule, or detached into a standalone bottom bar. */
|
|
70
|
+
readonly statusAttachment: "top-border" | "top-band" | "top-rule-chip" | "none";
|
|
71
71
|
/** Which segment groups the standalone bottom status bar shows. */
|
|
72
72
|
readonly bottomBar: "none" | "left" | "full";
|
|
73
73
|
/** Insert a blank spacer row between the editor and the standalone bottom
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { TUI } from "../tui.js";
|
|
2
2
|
import { Text } from "./text.js";
|
|
3
|
+
/** Milliseconds between spinner-frame advances; exported so time-derived spinners elsewhere tick at the Loader cadence. */
|
|
4
|
+
export declare const SPINNER_ADVANCE_MS = 80;
|
|
3
5
|
type ColorFn = (str: string) => string;
|
|
4
6
|
/**
|
|
5
7
|
* Styles Loader message fragments without changing their visible text or width.
|
|
@@ -22,6 +24,10 @@ export declare class Loader extends Text {
|
|
|
22
24
|
stop(): void;
|
|
23
25
|
/** Lifecycle teardown: stop the animation timer. Idempotent. */
|
|
24
26
|
dispose(): void;
|
|
27
|
+
/** Install a lazy right-docked suffix for the spinner row (e.g. a styled
|
|
28
|
+
* session title). Re-evaluated every paint; dropped when the row leaves
|
|
29
|
+
* less than a two-cell gap. */
|
|
30
|
+
setTrailer(trailer: (() => string | undefined) | undefined): void;
|
|
25
31
|
setMessage(message: string): void;
|
|
26
32
|
}
|
|
27
33
|
export {};
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/pi-tui",
|
|
4
|
-
"version": "18.0.
|
|
4
|
+
"version": "18.0.10",
|
|
5
5
|
"description": "Terminal User Interface library with differential rendering for efficient text-based applications",
|
|
6
6
|
"homepage": "https://omp.sh",
|
|
7
7
|
"author": "Stencil Labs, Inc.",
|
|
@@ -37,8 +37,8 @@
|
|
|
37
37
|
"fmt": "biome format --write ."
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@oh-my-pi/pi-natives": "18.0.
|
|
41
|
-
"@oh-my-pi/pi-utils": "18.0.
|
|
40
|
+
"@oh-my-pi/pi-natives": "18.0.10",
|
|
41
|
+
"@oh-my-pi/pi-utils": "18.0.10"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
44
|
"kitty-vt-wasm": "^0.2.0"
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Soft status band composer (the rust omp default): the full status line sits
|
|
3
|
+
* flush against the terminal's left edge as a filled powerline band with a
|
|
4
|
+
* soft opening cap — no frame, rules, or corners — above an unboxed prompt
|
|
5
|
+
* anchored by a single curved `╰─ ` cue.
|
|
6
|
+
*/
|
|
7
|
+
import { truncateToWidth } from "../../utils";
|
|
8
|
+
import type { ComposerChromeContext, ComposerRowContext, ComposerStyle } from "./types";
|
|
9
|
+
|
|
10
|
+
export const bandComposerStyle: ComposerStyle = {
|
|
11
|
+
id: "band",
|
|
12
|
+
sideBorders: false,
|
|
13
|
+
verticalChrome: 1,
|
|
14
|
+
statusAttachment: "top-band",
|
|
15
|
+
bottomBar: "none",
|
|
16
|
+
bottomBarGap: false,
|
|
17
|
+
defaultPromptGutter: "╰─ ",
|
|
18
|
+
|
|
19
|
+
defaultPaddingX(): number {
|
|
20
|
+
return 0;
|
|
21
|
+
},
|
|
22
|
+
|
|
23
|
+
sideChromeWidth(): number {
|
|
24
|
+
return 0;
|
|
25
|
+
},
|
|
26
|
+
|
|
27
|
+
renderTop(ctx: ComposerChromeContext): string | undefined {
|
|
28
|
+
const { topBorder, width } = ctx;
|
|
29
|
+
if (!topBorder?.content) return undefined;
|
|
30
|
+
// The band builder already sizes its groups + gauge to the full width;
|
|
31
|
+
// truncation only guards against a stale provider during resize.
|
|
32
|
+
return topBorder.width > width ? truncateToWidth(topBorder.content, width) : topBorder.content;
|
|
33
|
+
},
|
|
34
|
+
|
|
35
|
+
renderRow(ctx: ComposerRowContext): string[] {
|
|
36
|
+
return [(ctx.gutter ? ctx.borderColor(ctx.gutter) : "") + ctx.text + ctx.pad];
|
|
37
|
+
},
|
|
38
|
+
|
|
39
|
+
renderBottom(): undefined {
|
|
40
|
+
return undefined;
|
|
41
|
+
},
|
|
42
|
+
};
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { bandComposerStyle } from "./band";
|
|
1
2
|
import { borderlessComposerStyle } from "./borderless";
|
|
2
3
|
import { boxComposerStyle } from "./box";
|
|
3
4
|
import { claudeComposerStyle } from "./claude";
|
|
@@ -9,6 +10,7 @@ import type { ComposerStyle, EditorBorderStyle } from "./types";
|
|
|
9
10
|
|
|
10
11
|
const BUILTIN_COMPOSER_STYLES: Readonly<Record<string, ComposerStyle>> = {
|
|
11
12
|
box: boxComposerStyle,
|
|
13
|
+
band: bandComposerStyle,
|
|
12
14
|
claude: claudeComposerStyle,
|
|
13
15
|
pi: piComposerStyle,
|
|
14
16
|
borderless: borderlessComposerStyle,
|
|
@@ -12,7 +12,16 @@ import type { SymbolTheme } from "../../symbols";
|
|
|
12
12
|
export type ComposerBox = SymbolTheme["boxRound"];
|
|
13
13
|
|
|
14
14
|
/** Built-in composer shape identifiers shipped by pi-tui. */
|
|
15
|
-
export const BUILTIN_EDITOR_BORDER_STYLES = [
|
|
15
|
+
export const BUILTIN_EDITOR_BORDER_STYLES = [
|
|
16
|
+
"box",
|
|
17
|
+
"band",
|
|
18
|
+
"claude",
|
|
19
|
+
"pi",
|
|
20
|
+
"borderless",
|
|
21
|
+
"rule",
|
|
22
|
+
"field",
|
|
23
|
+
"rail",
|
|
24
|
+
] as const;
|
|
16
25
|
|
|
17
26
|
/** Identifier for a built-in composer shape. */
|
|
18
27
|
export type BuiltinEditorBorderStyle = (typeof BUILTIN_EDITOR_BORDER_STYLES)[number];
|
|
@@ -74,8 +83,8 @@ export interface ComposerStyle {
|
|
|
74
83
|
/** Rows consumed by top+bottom chrome (drives maxHeight budgeting). */
|
|
75
84
|
readonly verticalChrome: 0 | 1 | 2;
|
|
76
85
|
/** Where the host should attach the status bar: embedded in the top border,
|
|
77
|
-
* docked onto a top rule, or detached into a standalone bottom bar. */
|
|
78
|
-
readonly statusAttachment: "top-border" | "top-rule-chip" | "none";
|
|
86
|
+
* rendered as a flush soft-capped band above the input, docked onto a top rule, or detached into a standalone bottom bar. */
|
|
87
|
+
readonly statusAttachment: "top-border" | "top-band" | "top-rule-chip" | "none";
|
|
79
88
|
/** Which segment groups the standalone bottom status bar shows. */
|
|
80
89
|
readonly bottomBar: "none" | "left" | "full";
|
|
81
90
|
/** Insert a blank spacer row between the editor and the standalone bottom
|
package/src/components/editor.ts
CHANGED
|
@@ -1403,11 +1403,17 @@ export class Editor implements Component, Focusable {
|
|
|
1403
1403
|
this.#cancelAutocomplete(true);
|
|
1404
1404
|
return;
|
|
1405
1405
|
}
|
|
1406
|
+
// Right arrow at end of line accepts the selection like Tab (fish-style).
|
|
1407
|
+
// Mid-line, right arrow keeps its cursor-movement role and falls through.
|
|
1408
|
+
const rightArrowAccepts =
|
|
1409
|
+
kb.matchesCanonical(canonical, "tui.editor.cursorRight") &&
|
|
1410
|
+
this.#state.cursorCol >= (this.#state.lines[this.#state.cursorLine] ?? "").length;
|
|
1406
1411
|
if (
|
|
1407
1412
|
this.#autocompleteState === "assist" &&
|
|
1408
1413
|
(kb.matchesCanonical(canonical, "tui.input.submit") ||
|
|
1409
1414
|
data === "\n" ||
|
|
1410
|
-
kb.matchesCanonical(canonical, "tui.input.tab")
|
|
1415
|
+
kb.matchesCanonical(canonical, "tui.input.tab") ||
|
|
1416
|
+
rightArrowAccepts)
|
|
1411
1417
|
) {
|
|
1412
1418
|
this.#applySpellingSuggestion();
|
|
1413
1419
|
return;
|
|
@@ -1420,7 +1426,8 @@ export class Editor implements Component, Focusable {
|
|
|
1420
1426
|
kb.matchesCanonical(canonical, "tui.select.pageDown") ||
|
|
1421
1427
|
kb.matchesCanonical(canonical, "tui.input.submit") ||
|
|
1422
1428
|
data === "\n" ||
|
|
1423
|
-
kb.matchesCanonical(canonical, "tui.input.tab")
|
|
1429
|
+
kb.matchesCanonical(canonical, "tui.input.tab") ||
|
|
1430
|
+
rightArrowAccepts
|
|
1424
1431
|
) {
|
|
1425
1432
|
// Only pass navigation keys to the list, not Enter/Tab (we handle those directly)
|
|
1426
1433
|
if (
|
|
@@ -1435,7 +1442,7 @@ export class Editor implements Component, Focusable {
|
|
|
1435
1442
|
}
|
|
1436
1443
|
|
|
1437
1444
|
// If Tab was pressed, always apply the selection
|
|
1438
|
-
if (kb.matchesCanonical(canonical, "tui.input.tab")) {
|
|
1445
|
+
if (kb.matchesCanonical(canonical, "tui.input.tab") || rightArrowAccepts) {
|
|
1439
1446
|
const selected = this.#autocompleteList.getSelectedItem();
|
|
1440
1447
|
// Check for stale autocomplete state due to buffer edits since last refresh
|
|
1441
1448
|
// (destructive keys or paste can outrun the debounced update).
|
|
@@ -1731,6 +1738,13 @@ export class Editor implements Component, Focusable {
|
|
|
1731
1738
|
}
|
|
1732
1739
|
} else if (kb.matchesCanonical(canonical, "tui.editor.cursorRight")) {
|
|
1733
1740
|
// Right
|
|
1741
|
+
// At end of line, accept the inline ghost word completion (IME-style) like Tab.
|
|
1742
|
+
if (
|
|
1743
|
+
this.#state.cursorCol >= (this.#state.lines[this.#state.cursorLine] ?? "").length &&
|
|
1744
|
+
this.#acceptWordCompletion()
|
|
1745
|
+
) {
|
|
1746
|
+
return;
|
|
1747
|
+
}
|
|
1734
1748
|
this.#moveCursor(0, 1);
|
|
1735
1749
|
} else if (kb.matchesCanonical(canonical, "tui.editor.cursorLeft")) {
|
|
1736
1750
|
// Left
|
|
@@ -3435,13 +3449,7 @@ export class Editor implements Component, Focusable {
|
|
|
3435
3449
|
}
|
|
3436
3450
|
|
|
3437
3451
|
async #handleTabCompletion(): Promise<void> {
|
|
3438
|
-
|
|
3439
|
-
if (wordCompletion) {
|
|
3440
|
-
const currentLine = this.#state.lines[this.#state.cursorLine] ?? "";
|
|
3441
|
-
const after = currentLine.slice(this.#state.cursorCol);
|
|
3442
|
-
this.#insertTextAtCursor(wordCompletion + (/^[\s.,;:!?"\])}]/.test(after) ? "" : " "));
|
|
3443
|
-
return;
|
|
3444
|
-
}
|
|
3452
|
+
if (this.#acceptWordCompletion()) return;
|
|
3445
3453
|
if (!this.#autocompleteProvider) return;
|
|
3446
3454
|
|
|
3447
3455
|
const currentLine = this.#state.lines[this.#state.cursorLine] || "";
|
|
@@ -3458,6 +3466,16 @@ export class Editor implements Component, Focusable {
|
|
|
3458
3466
|
await this.#forceFileAutocomplete();
|
|
3459
3467
|
}
|
|
3460
3468
|
}
|
|
3469
|
+
/** Insert the inline ghost word completion at the cursor, if any. Shared by Tab and right-arrow accept. */
|
|
3470
|
+
#acceptWordCompletion(): boolean {
|
|
3471
|
+
const wordCompletion = this.#getWordCompletion();
|
|
3472
|
+
if (!wordCompletion) return false;
|
|
3473
|
+
const currentLine = this.#state.lines[this.#state.cursorLine] ?? "";
|
|
3474
|
+
const after = currentLine.slice(this.#state.cursorCol);
|
|
3475
|
+
this.#insertTextAtCursor(wordCompletion + (/^[\s.,;:!?"\])}]/.test(after) ? "" : " "));
|
|
3476
|
+
return true;
|
|
3477
|
+
}
|
|
3478
|
+
|
|
3461
3479
|
async #showSpellingSuggestions(): Promise<void> {
|
|
3462
3480
|
const cursorLine = this.#state.cursorLine;
|
|
3463
3481
|
const cursorCol = this.#state.cursorCol;
|
package/src/components/loader.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import type { TUI } from "../tui";
|
|
2
|
-
import { getPaddingX, sliceByColumn, visibleWidth } from "../utils";
|
|
2
|
+
import { getPaddingX, padding, sliceByColumn, visibleWidth } from "../utils";
|
|
3
3
|
import { Text } from "./text";
|
|
4
4
|
|
|
5
5
|
const RENDER_INTERVAL_MS = 1000 / 30;
|
|
6
|
-
|
|
6
|
+
/** Milliseconds between spinner-frame advances; exported so time-derived spinners elsewhere tick at the Loader cadence. */
|
|
7
|
+
export const SPINNER_ADVANCE_MS = 80;
|
|
7
8
|
const RENDER_BACKPRESSURE_MULTIPLIER = 9;
|
|
8
9
|
const MAX_BACKPRESSURE_FRAME_COST_MS = 200;
|
|
9
10
|
|
|
@@ -28,6 +29,7 @@ export class Loader extends Text {
|
|
|
28
29
|
#layout?: readonly { leading: string; content: string; trailing: string }[];
|
|
29
30
|
#layoutFrames: readonly string[];
|
|
30
31
|
#layoutFrame: string;
|
|
32
|
+
#trailer?: () => string | undefined;
|
|
31
33
|
|
|
32
34
|
constructor(
|
|
33
35
|
ui: TUI,
|
|
@@ -102,6 +104,15 @@ export class Loader extends Text {
|
|
|
102
104
|
lines.push(`${leading}${content ? this.messageColorFn(content) : ""}${trailing}`);
|
|
103
105
|
}
|
|
104
106
|
}
|
|
107
|
+
if (this.#trailer && lines.length > 1) {
|
|
108
|
+
const trailer = this.#trailer();
|
|
109
|
+
if (trailer) {
|
|
110
|
+
// Text pads rows to full width; drop that pad before docking right.
|
|
111
|
+
const body = lines[1].trimEnd();
|
|
112
|
+
const gap = width - visibleWidth(body) - visibleWidth(trailer);
|
|
113
|
+
if (gap >= 2) lines[1] = body + padding(gap) + trailer;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
105
116
|
return lines;
|
|
106
117
|
}
|
|
107
118
|
|
|
@@ -124,6 +135,12 @@ export class Loader extends Text {
|
|
|
124
135
|
dispose() {
|
|
125
136
|
this.stop();
|
|
126
137
|
}
|
|
138
|
+
/** Install a lazy right-docked suffix for the spinner row (e.g. a styled
|
|
139
|
+
* session title). Re-evaluated every paint; dropped when the row leaves
|
|
140
|
+
* less than a two-cell gap. */
|
|
141
|
+
setTrailer(trailer: (() => string | undefined) | undefined): void {
|
|
142
|
+
this.#trailer = trailer;
|
|
143
|
+
}
|
|
127
144
|
|
|
128
145
|
setMessage(message: string) {
|
|
129
146
|
if (message === this.message) {
|