omp-claude-tui 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +141 -2
- package/index.ts +338 -0
- package/package.json +18 -5
- package/src/render.ts +151 -0
- package/src/scroll-mode.ts +396 -0
- package/src/selection.ts +166 -0
- package/src/tools.ts +382 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jack-01041202
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,142 @@
|
|
|
1
|
-
#
|
|
1
|
+
# omp-claude-tui
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Claude Code-style transcript for [omp](https://omp.sh): `● Tool(arg)` cards with a
|
|
4
|
+
`⎿` result gutter, a fullscreen scroll mode with omp's own input docked at the
|
|
5
|
+
bottom, and in-app drag-to-copy.
|
|
6
|
+
|
|
7
|
+
> **Compatibility:** tested on omp **18.16.0** only. omp has no public API for
|
|
8
|
+
> this, so the plugin hooks omp internals (see [How it works / risk](#how-it-works--risk)).
|
|
9
|
+
> On another omp version a feature may stop working; it then falls back to omp's
|
|
10
|
+
> own look instead of crashing, and `/cc-tui status` shows what is unavailable.
|
|
11
|
+
|
|
12
|
+
```
|
|
13
|
+
● Bash(seq 1 10)
|
|
14
|
+
⎿ 1
|
|
15
|
+
2
|
|
16
|
+
3
|
|
17
|
+
4
|
|
18
|
+
… +6 lines (ctrl+o to expand)
|
|
19
|
+
|
|
20
|
+
● Update(notes.txt)
|
|
21
|
+
⎿ Updated notes.txt with 1 addition and 1 removal
|
|
22
|
+
1 alpha
|
|
23
|
+
2 - beta
|
|
24
|
+
2 + BETA
|
|
25
|
+
3 gamma
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Install
|
|
29
|
+
|
|
30
|
+
```sh
|
|
31
|
+
omp plugin install omp-claude-tui
|
|
32
|
+
# or straight from GitHub:
|
|
33
|
+
omp plugin install github:Jack-01041202/omp-claude-tui
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Restart omp afterwards; already-open sessions keep their look. Then optionally:
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
/cc-tui fullscreen on # start every session in fullscreen scroll mode
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Update with `omp plugin upgrade omp-claude-tui`, remove with
|
|
43
|
+
`omp plugin uninstall omp-claude-tui`.
|
|
44
|
+
|
|
45
|
+
For development, link a checkout instead: `omp plugin link ~/omp-claude-tui`.
|
|
46
|
+
|
|
47
|
+
## What it changes
|
|
48
|
+
|
|
49
|
+
| Surface | Result |
|
|
50
|
+
|---|---|
|
|
51
|
+
| `bash` | `● Bash(cmd)`, output under `⎿`, omp's exit/wall-time notices removed, `Exit code N` on failure |
|
|
52
|
+
| `write` | `Wrote N lines to path` plus numbered preview |
|
|
53
|
+
| `edit`, `apply_patch` | `Updated path with N additions and M removals` plus diff |
|
|
54
|
+
| `grep`, `glob`, `find` | `Search(...)` / `Find(...)` with `Found N …`; listing on Ctrl+O |
|
|
55
|
+
| `todo` | `Update Todos` checklist (`☐` / `☒`) |
|
|
56
|
+
| `read` (URL reads only) | `Read N lines` |
|
|
57
|
+
| Working row | `<current intent>… (12s)` |
|
|
58
|
+
| Composer | omp's built-in `claude` shape, only when `composer.shape` is not configured |
|
|
59
|
+
| Input position | pinned to the bottom row even while the transcript is shorter than the screen (blank rows padded on top) |
|
|
60
|
+
|
|
61
|
+
Bullet: dim while pending, blinking while running, green on success, red on failure.
|
|
62
|
+
|
|
63
|
+
Not changed: plain file reads (omp draws them in its own grouped read card), `task`
|
|
64
|
+
(live subagent cards use the tool's own renderer), MCP and other tools, the footer
|
|
65
|
+
(`setFooter` is a no-op in omp's interactive mode), and Tern's native view.
|
|
66
|
+
|
|
67
|
+
## Scroll mode
|
|
68
|
+
|
|
69
|
+
omp renders the main session inline, so scrolling the terminal back moves the
|
|
70
|
+
input away with everything else. Press `PgUp` (or run `/cc-tui scroll`) to open
|
|
71
|
+
a fullscreen view: the session transcript scrolls on top, and omp's own input
|
|
72
|
+
editor stays docked at the bottom — the same editor instance, so slash and agent
|
|
73
|
+
command completion, `@` files, history, the status line and Enter behave as in
|
|
74
|
+
the normal view.
|
|
75
|
+
|
|
76
|
+
| Key | Action |
|
|
77
|
+
|---|---|
|
|
78
|
+
| Mouse wheel, `PgUp` / `PgDn` | scroll the transcript (scrolling to the bottom resumes following new output) |
|
|
79
|
+
| Drag | select text in the transcript; copies to the clipboard on release |
|
|
80
|
+
| Double-click / triple-click | select and copy a word / a line |
|
|
81
|
+
| `Ctrl+O` | expand tool output in the transcript |
|
|
82
|
+
| `Esc` | close an open completion menu, otherwise return to the normal view |
|
|
83
|
+
| any other key | goes to the input editor (and clears the selection) |
|
|
84
|
+
|
|
85
|
+
Copying uses `pbcopy` on macOS and OSC 52 elsewhere; the key hint row briefly
|
|
86
|
+
shows `Copied N characters`. Selection covers the transcript rows on screen
|
|
87
|
+
(no auto-scroll while dragging); scrolling clears it. Holding `Shift` still
|
|
88
|
+
gives the terminal's native selection.
|
|
89
|
+
|
|
90
|
+
When omp opens a dialog (approval, `ask`, a selector), scroll mode closes and the
|
|
91
|
+
dialog appears in the normal view.
|
|
92
|
+
|
|
93
|
+
### Default fullscreen
|
|
94
|
+
|
|
95
|
+
`/cc-tui fullscreen on` makes scroll mode the default, saved across launches:
|
|
96
|
+
every session (startup, `/new`, resume, fork) opens in it, and after a dialog is
|
|
97
|
+
answered the view comes back on its own. `Esc` switches to the normal view until
|
|
98
|
+
the next session starts; `PgUp` returns. `/cc-tui fullscreen off` restores the
|
|
99
|
+
normal view as default and closes the view.
|
|
100
|
+
|
|
101
|
+
Limits: new text appears once each message is saved to the session file, not
|
|
102
|
+
token by token; needs a saved session (not `--no-session`). `PgUp` no longer
|
|
103
|
+
pages a multi-line draft in the normal editor.
|
|
104
|
+
|
|
105
|
+
## Command
|
|
106
|
+
|
|
107
|
+
```
|
|
108
|
+
/cc-tui status current state
|
|
109
|
+
/cc-tui scroll open scroll mode (same as PgUp)
|
|
110
|
+
/cc-tui fullscreen on|off make scroll mode the default for every session (default off)
|
|
111
|
+
/cc-tui on | off toggle (applies to new and repainted cards)
|
|
112
|
+
/cc-tui lines <n> rows shown under ⎿ while collapsed (default 4)
|
|
113
|
+
/cc-tui pin on|off pin the input to the bottom row (default on)
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Settings persist in `~/.omp/agent/omp-claude-tui.json`.
|
|
117
|
+
|
|
118
|
+
## How it works / risk
|
|
119
|
+
|
|
120
|
+
omp exposes no public API for restyling built-in tool cards. The plugin wraps
|
|
121
|
+
entries of omp's internal renderer registry (`toolRenderers` from
|
|
122
|
+
`@oh-my-pi/pi-tui/tools`). This is display-only: tool definitions the model sees,
|
|
123
|
+
approval and execution are untouched. Each wrapped hook falls back to omp's
|
|
124
|
+
original renderer when it throws or the plugin is off. An omp upgrade that
|
|
125
|
+
changes the registry or the args/details shapes can revert cards to omp's look;
|
|
126
|
+
if the registry is missing, the plugin warns at session start and stays inert.
|
|
127
|
+
|
|
128
|
+
The bottom pin wraps omp's internal `Composer.prototype.renderFrame` (from
|
|
129
|
+
`@oh-my-pi/pi-tui/prompt/composer`) and pads short frames with blank rows on top.
|
|
130
|
+
If that module is missing, `/cc-tui status` reports `pin input unavailable`.
|
|
131
|
+
With `tui.mouse` on, clicks on live subagent rows can land a few rows off while
|
|
132
|
+
the frame is padded, since omp hit-tests against the unpadded frame.
|
|
133
|
+
|
|
134
|
+
Scroll mode mounts omp's internal fullscreen transcript viewer
|
|
135
|
+
(`@oh-my-pi/pi-tui/overlays/agent-transcript-viewer`, the one Agent Hub uses)
|
|
136
|
+
read-only on the main session file, parsed with
|
|
137
|
+
`@oh-my-pi/pi-coding-agent/modes/agent-hub-runtime`, and docks the live editor
|
|
138
|
+
taken from omp's composer (recorded by the `renderFrame` wrapper). While open it
|
|
139
|
+
wraps `tui.setFocus` to leave the view when focus moves to a dialog. If a module
|
|
140
|
+
is missing, `/cc-tui status` reports `scroll mode unavailable`.
|
|
141
|
+
Native fullscreen support is tracked upstream in
|
|
142
|
+
[can1357/oh-my-pi#10232](https://github.com/can1357/oh-my-pi/issues/10232).
|
package/index.ts
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* omp-claude-tui: Claude Code-style transcript for omp.
|
|
3
|
+
*
|
|
4
|
+
* Tool cards: wraps entries of omp's internal built-in renderer registry
|
|
5
|
+
* (`toolRenderers` from `@oh-my-pi/pi-tui/tools`). Display-only — model-facing
|
|
6
|
+
* tool definitions, approval and execution are untouched. Every wrapped hook
|
|
7
|
+
* falls back to omp's original renderer when ours throws or the plugin is off.
|
|
8
|
+
*
|
|
9
|
+
* Also: a live working-row label and the built-in `claude` composer shape
|
|
10
|
+
* (pinned only while the user has not configured `composer.shape`).
|
|
11
|
+
*/
|
|
12
|
+
import fs from "node:fs";
|
|
13
|
+
import os from "node:os";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import type { ExtensionAPI, ExtensionContext } from "@oh-my-pi/pi-coding-agent";
|
|
16
|
+
import { lookup } from "@oh-my-pi/pi-coding-agent/config/registry";
|
|
17
|
+
import { type ToolRenderer, toolRenderers } from "@oh-my-pi/pi-tui/tools";
|
|
18
|
+
import { createScrollMode } from "./src/scroll-mode";
|
|
19
|
+
import { type ClaudeRenderer, createRenderers, type NoticeStrippers } from "./src/tools";
|
|
20
|
+
|
|
21
|
+
const ORIGINAL = Symbol.for("omp-claude-tui.original");
|
|
22
|
+
const ORIGINAL_FRAME = Symbol.for("omp-claude-tui.renderFrame");
|
|
23
|
+
const CONFIG_PATH = path.join(os.homedir(), ".omp", "agent", "omp-claude-tui.json");
|
|
24
|
+
const DEFAULT_PREVIEW_LINES = 4;
|
|
25
|
+
/** `/cc-tui` arguments shown in the completion menu; `"<sub> on"` forms appear once the subcommand is typed. */
|
|
26
|
+
const CC_TUI_ARGS: readonly (readonly [value: string, description: string])[] = [
|
|
27
|
+
["scroll", "Open scroll mode (fullscreen, input pinned at the bottom); same as PgUp"],
|
|
28
|
+
["fullscreen", "Default fullscreen: every session opens in scroll mode"],
|
|
29
|
+
["fullscreen on", "Make fullscreen the default and enter it now"],
|
|
30
|
+
["fullscreen off", "Stop defaulting to fullscreen and return to the normal view"],
|
|
31
|
+
["on", "Enable the plugin (Claude-style tool cards, working row)"],
|
|
32
|
+
["off", "Disable the plugin and restore omp's native look"],
|
|
33
|
+
["lines", "Rows shown under ⎿ while tool output is collapsed, e.g. lines 6"],
|
|
34
|
+
["pin", "Push the input to the bottom when the transcript is shorter than the screen"],
|
|
35
|
+
["pin on", "Pin the input to the bottom"],
|
|
36
|
+
["pin off", "Stop pinning the input to the bottom"],
|
|
37
|
+
["status", "Show current settings"],
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
interface Config {
|
|
41
|
+
enabled: boolean;
|
|
42
|
+
previewLines: number;
|
|
43
|
+
/** Keep the input on the bottom row while the transcript is shorter than the screen. */
|
|
44
|
+
pinInput: boolean;
|
|
45
|
+
/** Default fullscreen: every session starts in scroll mode and returns to it after dialogs. */
|
|
46
|
+
fullscreen: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function loadConfig(): Config {
|
|
50
|
+
const config: Config = { enabled: true, previewLines: DEFAULT_PREVIEW_LINES, pinInput: true, fullscreen: false };
|
|
51
|
+
try {
|
|
52
|
+
const raw = JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8")) as Partial<Config>;
|
|
53
|
+
if (typeof raw.enabled === "boolean") config.enabled = raw.enabled;
|
|
54
|
+
if (typeof raw.previewLines === "number" && raw.previewLines >= 1) config.previewLines = Math.floor(raw.previewLines);
|
|
55
|
+
if (typeof raw.pinInput === "boolean") config.pinInput = raw.pinInput;
|
|
56
|
+
if (typeof raw.fullscreen === "boolean") config.fullscreen = raw.fullscreen;
|
|
57
|
+
} catch {
|
|
58
|
+
// Missing or unreadable config: defaults.
|
|
59
|
+
}
|
|
60
|
+
return config;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function saveConfig(config: Config): void {
|
|
64
|
+
fs.mkdirSync(path.dirname(CONFIG_PATH), { recursive: true });
|
|
65
|
+
fs.writeFileSync(CONFIG_PATH, `${JSON.stringify(config, null, "\t")}\n`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
type Wrapped = ToolRenderer & { [ORIGINAL]?: ToolRenderer };
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Replace each registry entry with a delegating wrapper. Wrappers are built from
|
|
72
|
+
* the pristine renderer (kept under ORIGINAL), so a plugin reload re-wraps
|
|
73
|
+
* instead of stacking wrappers. Returns the names actually wrapped.
|
|
74
|
+
*/
|
|
75
|
+
function install(renderers: Record<string, ClaudeRenderer>, config: Config): string[] {
|
|
76
|
+
const installed: string[] = [];
|
|
77
|
+
for (const [name, ours] of Object.entries(renderers)) {
|
|
78
|
+
const current = toolRenderers[name] as Wrapped | undefined;
|
|
79
|
+
const base = current?.[ORIGINAL] ?? current;
|
|
80
|
+
if (!base || typeof base.renderCall !== "function" || typeof base.renderResult !== "function") continue;
|
|
81
|
+
const wrapped: Wrapped = {
|
|
82
|
+
...base,
|
|
83
|
+
renderCall(args, options, theme) {
|
|
84
|
+
if (!config.enabled) return base.renderCall(args, options, theme);
|
|
85
|
+
try {
|
|
86
|
+
return ours.renderCall(args, options, theme);
|
|
87
|
+
} catch {
|
|
88
|
+
return base.renderCall(args, options, theme);
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
renderResult(result, options, theme, args) {
|
|
92
|
+
if (!config.enabled) return base.renderResult(result, options, theme, args);
|
|
93
|
+
try {
|
|
94
|
+
return ours.renderResult(result, options, theme, args);
|
|
95
|
+
} catch {
|
|
96
|
+
return base.renderResult(result, options, theme, args);
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
// Read at render time by omp, so toggling the plugin applies to existing cards on their next repaint.
|
|
101
|
+
Object.defineProperties(wrapped, {
|
|
102
|
+
// Our result component redraws the header, so it can carry the status color.
|
|
103
|
+
mergeCallAndResult: { get: () => (config.enabled ? true : base.mergeCallAndResult), enumerable: true },
|
|
104
|
+
// No background box: Claude-style rows sit in the response flow.
|
|
105
|
+
inline: { get: () => (config.enabled ? true : base.inline), enumerable: true },
|
|
106
|
+
// The running bullet blinks on the shared spinner tick.
|
|
107
|
+
animatedPendingPreview: { get: () => (config.enabled ? true : base.animatedPendingPreview), enumerable: true },
|
|
108
|
+
animatedPartialResult: { get: () => (config.enabled ? true : base.animatedPartialResult), enumerable: true },
|
|
109
|
+
});
|
|
110
|
+
wrapped[ORIGINAL] = base;
|
|
111
|
+
toolRenderers[name] = wrapped;
|
|
112
|
+
installed.push(name);
|
|
113
|
+
}
|
|
114
|
+
return installed;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* omp's own notice strippers, when its module resolves inside this omp build.
|
|
119
|
+
* Dynamic on purpose: `tools/output-meta` is an internal subpath that a future
|
|
120
|
+
* omp build may drop, and a failed static import would fail the whole plugin load.
|
|
121
|
+
*/
|
|
122
|
+
async function loadStrippers(): Promise<NoticeStrippers> {
|
|
123
|
+
try {
|
|
124
|
+
const mod: NoticeStrippers = await import("@oh-my-pi/pi-tui/tools/output-meta");
|
|
125
|
+
return { stripOutputNotice: mod.stripOutputNotice, stripRawOutputArtifactNotice: mod.stripRawOutputArtifactNotice };
|
|
126
|
+
} catch {
|
|
127
|
+
return {};
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Pins the built-in `claude` composer shape unless the user configured one. */
|
|
132
|
+
function composerShape(pi: ExtensionAPI): (enabled: boolean) => void {
|
|
133
|
+
const handle = lookup("composer.shape");
|
|
134
|
+
const scope = pi.pi.settings;
|
|
135
|
+
let pinned = false;
|
|
136
|
+
return enabled => {
|
|
137
|
+
if (!handle) return;
|
|
138
|
+
try {
|
|
139
|
+
if (enabled && !pinned && !handle.isConfigured(scope)) {
|
|
140
|
+
handle.override(scope, "claude");
|
|
141
|
+
pinned = true;
|
|
142
|
+
} else if (!enabled && pinned) {
|
|
143
|
+
handle.clearOverride(scope);
|
|
144
|
+
pinned = false;
|
|
145
|
+
}
|
|
146
|
+
} catch {
|
|
147
|
+
// Unknown setting shape in this omp build: leave the composer alone.
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function formatElapsed(ms: number): string {
|
|
153
|
+
const s = Math.floor(ms / 1000);
|
|
154
|
+
return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Working-row label `<intent>… (12s)`, re-applied after omp swaps in each tool intent. */
|
|
158
|
+
function workingRow(pi: ExtensionAPI, config: Config): void {
|
|
159
|
+
let start = 0;
|
|
160
|
+
let intent: string | undefined;
|
|
161
|
+
let active = false;
|
|
162
|
+
const label = () => `${intent ?? "Working"}… (${formatElapsed(Date.now() - start)})`;
|
|
163
|
+
// omp's interactive controller accepts a live label function; the declared type is string.
|
|
164
|
+
const apply = (ctx: ExtensionContext) => {
|
|
165
|
+
if (active && config.enabled && ctx.hasUI) ctx.ui.setWorkingMessage(label as unknown as string);
|
|
166
|
+
};
|
|
167
|
+
pi.on("agent_start", (_event, ctx) => {
|
|
168
|
+
start = Date.now();
|
|
169
|
+
intent = undefined;
|
|
170
|
+
active = true;
|
|
171
|
+
apply(ctx);
|
|
172
|
+
});
|
|
173
|
+
pi.on("tool_execution_start", (event, ctx) => {
|
|
174
|
+
const next = event.intent?.trim().replace(/\.+$/, "");
|
|
175
|
+
if (next) intent = next;
|
|
176
|
+
apply(ctx);
|
|
177
|
+
});
|
|
178
|
+
pi.on("agent_end", (_event, ctx) => {
|
|
179
|
+
active = false;
|
|
180
|
+
if (config.enabled && ctx.hasUI) ctx.ui.setWorkingMessage();
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
interface FrameViewport {
|
|
185
|
+
readonly columns: number;
|
|
186
|
+
readonly rows: number;
|
|
187
|
+
}
|
|
188
|
+
interface FramePlan {
|
|
189
|
+
readonly viewport: readonly string[];
|
|
190
|
+
}
|
|
191
|
+
type RenderFrame = (this: unknown, viewport: FrameViewport) => FramePlan;
|
|
192
|
+
|
|
193
|
+
/** The composer omp is painting with, recorded on each frame; scroll mode docks its editor and status line. */
|
|
194
|
+
let liveComposer: { readonly editor?: unknown; readonly ui?: { readonly children?: readonly unknown[] } } | undefined;
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* The composer's status-line slot (host · model · cwd · branch). omp keeps it in a
|
|
198
|
+
* private field but always mounts it as the TUI's last root (`Composer` re-adds it
|
|
199
|
+
* after every `setRuntimeChildren`); it is the only root with a `setComponent` slot.
|
|
200
|
+
*/
|
|
201
|
+
function liveStatusLine(): unknown {
|
|
202
|
+
const children = liveComposer?.ui?.children ?? [];
|
|
203
|
+
const last = children.at(-1);
|
|
204
|
+
return last && typeof last === "object" && "setComponent" in last && "render" in last ? last : undefined;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Pin the input to the bottom row. omp paints its live area (transcript tail,
|
|
209
|
+
* editor, status) directly below whatever the terminal held at startup, so a
|
|
210
|
+
* short session leaves the input mid-screen; once content fills the screen omp
|
|
211
|
+
* already keeps it on the bottom row. Padding a short frame with blank rows on
|
|
212
|
+
* top pushes it down to the bottom row in that remaining case.
|
|
213
|
+
*
|
|
214
|
+
* Dynamic on purpose: `prompt/composer` is an internal subpath; a failed static
|
|
215
|
+
* import would fail the whole plugin load. Returns false when unavailable.
|
|
216
|
+
*/
|
|
217
|
+
async function pinInput(config: Config): Promise<boolean> {
|
|
218
|
+
let proto: Record<PropertyKey, unknown>;
|
|
219
|
+
try {
|
|
220
|
+
const mod: { Composer?: { prototype: Record<PropertyKey, unknown> } } = await import(
|
|
221
|
+
"@oh-my-pi/pi-tui/prompt/composer"
|
|
222
|
+
);
|
|
223
|
+
if (!mod.Composer) return false;
|
|
224
|
+
proto = mod.Composer.prototype;
|
|
225
|
+
} catch {
|
|
226
|
+
return false;
|
|
227
|
+
}
|
|
228
|
+
// Wrap the pristine method so a plugin reload does not stack wrappers.
|
|
229
|
+
const original = (proto[ORIGINAL_FRAME] ?? proto.renderFrame) as RenderFrame | undefined;
|
|
230
|
+
if (typeof original !== "function") return false;
|
|
231
|
+
proto[ORIGINAL_FRAME] = original;
|
|
232
|
+
proto.renderFrame = function (this: NonNullable<typeof liveComposer>, viewport: FrameViewport): FramePlan {
|
|
233
|
+
liveComposer = this;
|
|
234
|
+
const plan = original.call(this, viewport);
|
|
235
|
+
if (!config.enabled || !config.pinInput) return plan;
|
|
236
|
+
const rows = viewport.rows;
|
|
237
|
+
const lines = plan.viewport;
|
|
238
|
+
if (lines.length === 0 || lines.length >= rows) return plan;
|
|
239
|
+
return { ...plan, viewport: [...Array<string>(rows - lines.length).fill(""), ...lines] };
|
|
240
|
+
};
|
|
241
|
+
return true;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* One settings object for the whole process. omp re-runs the factory for every
|
|
246
|
+
* subagent session, while the hooks it installs (renderer registry, composer
|
|
247
|
+
* prototype) are process-global; each run re-installs them over its own `config`,
|
|
248
|
+
* so sharing it keeps `/cc-tui` in the main session in charge of what is drawn.
|
|
249
|
+
*/
|
|
250
|
+
let sharedConfig: Config | undefined;
|
|
251
|
+
|
|
252
|
+
export default async function ompClaudeTui(pi: ExtensionAPI): Promise<void> {
|
|
253
|
+
const config = (sharedConfig ??= loadConfig());
|
|
254
|
+
const strippers = await loadStrippers();
|
|
255
|
+
const renderers = createRenderers({ previewLines: () => config.previewLines }, strippers);
|
|
256
|
+
const installed = install(renderers, config);
|
|
257
|
+
const applyComposer = composerShape(pi);
|
|
258
|
+
applyComposer(config.enabled);
|
|
259
|
+
workingRow(pi, config);
|
|
260
|
+
const pinned = await pinInput(config);
|
|
261
|
+
const scrollMode = await createScrollMode(pi, {
|
|
262
|
+
mainEditor: () => liveComposer?.editor,
|
|
263
|
+
mainStatusLine: liveStatusLine,
|
|
264
|
+
isDefault: () => config.enabled && config.fullscreen,
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
// Opening resolves when the view closes; report a failure instead of leaving it unhandled.
|
|
268
|
+
const openScrollMode = (ctx: ExtensionContext) => {
|
|
269
|
+
scrollMode.open(ctx).catch((error: unknown) => {
|
|
270
|
+
ctx.ui.notify(`omp-claude-tui: scroll mode failed: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
271
|
+
});
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
// PgUp opens scroll mode, as it scrolls Claude Code's fullscreen transcript.
|
|
275
|
+
// It takes over the editor's PgUp, which otherwise only pages a multi-line draft.
|
|
276
|
+
pi.registerShortcut("pageUp", {
|
|
277
|
+
description: "Scroll the transcript (fullscreen view, input stays at the bottom)",
|
|
278
|
+
handler: openScrollMode,
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
pi.on("session_start", (_event, ctx) => {
|
|
282
|
+
if (installed.length === 0) {
|
|
283
|
+
ctx.ui.notify("omp-claude-tui: omp's renderer registry has an unknown shape; using native rendering", "warning");
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
pi.registerCommand("cc-tui", {
|
|
288
|
+
description: "Claude-style transcript: scroll | fullscreen on|off | on | off | lines <n> | pin on|off | status",
|
|
289
|
+
getArgumentCompletions: prefix =>
|
|
290
|
+
CC_TUI_ARGS.filter(([value]) => value.startsWith(prefix) && (prefix.includes(" ") || !value.includes(" "))).map(
|
|
291
|
+
([value, description]) => ({ value, label: value, description }),
|
|
292
|
+
),
|
|
293
|
+
handler: async (args, ctx) => {
|
|
294
|
+
const [cmd = "status", value] = args.trim().split(/\s+/);
|
|
295
|
+
if (cmd === "scroll") {
|
|
296
|
+
openScrollMode(ctx);
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
if (cmd === "on" || cmd === "off") {
|
|
300
|
+
config.enabled = cmd === "on";
|
|
301
|
+
applyComposer(config.enabled);
|
|
302
|
+
if (!config.enabled && ctx.hasUI) ctx.ui.setWorkingMessage();
|
|
303
|
+
} else if (cmd === "lines") {
|
|
304
|
+
const n = Number(value);
|
|
305
|
+
if (!Number.isInteger(n) || n < 1) {
|
|
306
|
+
ctx.ui.notify("Usage: /cc-tui lines <positive integer>", "error");
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
config.previewLines = n;
|
|
310
|
+
} else if (cmd === "pin") {
|
|
311
|
+
if (value !== "on" && value !== "off") {
|
|
312
|
+
ctx.ui.notify("Usage: /cc-tui pin on|off", "error");
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
config.pinInput = value === "on";
|
|
316
|
+
} else if (cmd === "fullscreen") {
|
|
317
|
+
if (value !== "on" && value !== "off") {
|
|
318
|
+
ctx.ui.notify("Usage: /cc-tui fullscreen on|off", "error");
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
config.fullscreen = value === "on";
|
|
322
|
+
saveConfig(config);
|
|
323
|
+
if (config.fullscreen) openScrollMode(ctx);
|
|
324
|
+
else await scrollMode.close();
|
|
325
|
+
return;
|
|
326
|
+
} else if (cmd !== "status") {
|
|
327
|
+
ctx.ui.notify("Usage: /cc-tui scroll | fullscreen on|off | on | off | lines <n> | pin on|off | status", "error");
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
if (cmd !== "status") saveConfig(config);
|
|
331
|
+
const pin = pinned ? (config.pinInput ? "on" : "off") : "unavailable";
|
|
332
|
+
ctx.ui.notify(
|
|
333
|
+
`omp-claude-tui: ${config.enabled ? "on" : "off"} · preview ${config.previewLines} lines · pin input ${pin} · scroll mode ${scrollMode.available ? `PgUp, default ${config.fullscreen ? "on" : "off"}` : "unavailable"} · tools: ${installed.join(", ") || "none"}${cmd === "status" ? "" : " (new tool cards; scroll-back keeps its look)"}`,
|
|
334
|
+
"info",
|
|
335
|
+
);
|
|
336
|
+
},
|
|
337
|
+
});
|
|
338
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,19 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
"name": "omp-claude-tui",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Claude Code-style transcript, fullscreen scroll mode and in-app copy for omp (oh-my-pi)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Jack-01041202",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/Jack-01041202/omp-claude-tui.git"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/Jack-01041202/omp-claude-tui#readme",
|
|
13
|
+
"bugs": "https://github.com/Jack-01041202/omp-claude-tui/issues",
|
|
14
|
+
"keywords": ["omp-plugin", "oh-my-pi", "omp", "tui", "claude-code"],
|
|
15
|
+
"files": ["index.ts", "src", "README.md", "LICENSE"],
|
|
16
|
+
"omp": {
|
|
17
|
+
"extensions": ["./index.ts"]
|
|
18
|
+
}
|
|
19
|
+
}
|
package/src/render.ts
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared Claude Code-style rendering primitives: `● Name(arg)` headers and the
|
|
3
|
+
* `⎿` result gutter.
|
|
4
|
+
*/
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
import { type Component, replaceTabs, truncateToWidth } from "@oh-my-pi/pi-tui";
|
|
7
|
+
import type { ToolRenderer } from "@oh-my-pi/pi-tui/tools";
|
|
8
|
+
|
|
9
|
+
export type Theme = Parameters<ToolRenderer["renderCall"]>[2];
|
|
10
|
+
export type RenderOptions = Parameters<ToolRenderer["renderResult"]>[1];
|
|
11
|
+
export type RenderResult = Parameters<ToolRenderer["renderResult"]>[0];
|
|
12
|
+
|
|
13
|
+
const FIRST_GUTTER = " ⎿ ";
|
|
14
|
+
const NEXT_GUTTER = " ";
|
|
15
|
+
|
|
16
|
+
/** Static styled lines truncated to the render width; same array while the width is unchanged. */
|
|
17
|
+
export class Lines implements Component {
|
|
18
|
+
#lines: readonly string[];
|
|
19
|
+
#width = -1;
|
|
20
|
+
#cache: readonly string[] = [];
|
|
21
|
+
|
|
22
|
+
constructor(lines: readonly string[]) {
|
|
23
|
+
this.#lines = lines;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
render(width: number): readonly string[] {
|
|
27
|
+
if (width !== this.#width) {
|
|
28
|
+
this.#width = width;
|
|
29
|
+
this.#cache = this.#lines.map(line => truncateToWidth(line, width));
|
|
30
|
+
}
|
|
31
|
+
return this.#cache;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
invalidate(): void {
|
|
35
|
+
this.#width = -1;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type CallState = "pending" | "running" | "success" | "error";
|
|
40
|
+
|
|
41
|
+
/** Status bullet: blinking while running, dim while pending, green/red once settled. */
|
|
42
|
+
export function bullet(theme: Theme, state: CallState, spinnerFrame?: number): string {
|
|
43
|
+
switch (state) {
|
|
44
|
+
case "success":
|
|
45
|
+
return theme.fg("success", "●");
|
|
46
|
+
case "error":
|
|
47
|
+
return theme.fg("error", "●");
|
|
48
|
+
case "running":
|
|
49
|
+
return spinnerFrame !== undefined && spinnerFrame % 2 === 1 ? " " : theme.fg("dim", "●");
|
|
50
|
+
default:
|
|
51
|
+
return theme.fg("dim", "●");
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** State of a settled-or-streaming result. */
|
|
56
|
+
export function resultState(result: RenderResult, options: RenderOptions): CallState {
|
|
57
|
+
if (options.isPartial) return "running";
|
|
58
|
+
return result.isError ? "error" : "success";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* State of a call that has no result yet. omp ticks the spinner while the call's
|
|
63
|
+
* arguments are still streaming, so only `executionStarted` means it is running.
|
|
64
|
+
*/
|
|
65
|
+
export function callState(options: RenderOptions): CallState {
|
|
66
|
+
return options.executionStarted ? "running" : "pending";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** `● Name(arg)`; the parenthesised part is omitted when `arg` is undefined. */
|
|
70
|
+
export function header(theme: Theme, state: CallState, name: string, arg?: string, spinnerFrame?: number): string {
|
|
71
|
+
const head = `${bullet(theme, state, spinnerFrame)} ${theme.bold(name)}`;
|
|
72
|
+
if (arg === undefined) return head;
|
|
73
|
+
return `${head}${theme.fg("muted", "(")}${replaceTabs(arg)}${theme.fg("muted", ")")}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface GutterOptions {
|
|
77
|
+
expanded: boolean;
|
|
78
|
+
/** Body rows shown while collapsed. */
|
|
79
|
+
limit: number;
|
|
80
|
+
/** Collapse to the last rows (streaming output) instead of the first. */
|
|
81
|
+
tail?: boolean;
|
|
82
|
+
/** Always shown before the body (a summary sentence); not counted against `limit`. */
|
|
83
|
+
lead?: readonly string[];
|
|
84
|
+
/** Always shown after the body (exit code, timeout); not counted against `limit`. */
|
|
85
|
+
trailer?: readonly string[];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Rows under the header behind the `⎿` gutter. Collapsed, only `limit` body rows
|
|
90
|
+
* show (the tail while streaming), with an expand hint for the rest; `lead` and
|
|
91
|
+
* `trailer` rows are never folded away.
|
|
92
|
+
*/
|
|
93
|
+
export function gutter(theme: Theme, rows: readonly string[], opts: GutterOptions): string[] {
|
|
94
|
+
let shown: string[] = rows.map(row => replaceTabs(row));
|
|
95
|
+
let hidden = 0;
|
|
96
|
+
if (!opts.expanded && shown.length > opts.limit) {
|
|
97
|
+
hidden = shown.length - opts.limit;
|
|
98
|
+
shown = opts.tail ? shown.slice(-opts.limit) : shown.slice(0, opts.limit);
|
|
99
|
+
}
|
|
100
|
+
if (hidden > 0) {
|
|
101
|
+
const hint = theme.fg("dim", `… +${hidden} line${hidden === 1 ? "" : "s"} (ctrl+o to expand)`);
|
|
102
|
+
if (opts.tail) shown.unshift(hint);
|
|
103
|
+
else shown.push(hint);
|
|
104
|
+
}
|
|
105
|
+
const all = [...(opts.lead ?? []).map(replaceTabs), ...shown, ...(opts.trailer ?? []).map(replaceTabs)];
|
|
106
|
+
return all.map((row, i) => (i === 0 ? FIRST_GUTTER : NEXT_GUTTER) + row);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Joined text content of a tool result, trailing whitespace removed. */
|
|
110
|
+
export function resultText(result: RenderResult): string {
|
|
111
|
+
return result.content
|
|
112
|
+
.filter(block => block.type === "text" && typeof block.text === "string")
|
|
113
|
+
.map(block => block.text)
|
|
114
|
+
.join("\n")
|
|
115
|
+
.replace(/\s+$/, "");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Non-empty text as lines; empty text as no lines. */
|
|
119
|
+
export function splitLines(text: string | undefined): string[] {
|
|
120
|
+
if (!text) return [];
|
|
121
|
+
return text.replace(/\s+$/, "").split("\n");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** First line of a possibly multi-line value, marked with `…` when more follows. */
|
|
125
|
+
export function firstLine(text: unknown): string {
|
|
126
|
+
if (typeof text !== "string") return "";
|
|
127
|
+
const lines = text.trim().split("\n");
|
|
128
|
+
return lines.length > 1 ? `${lines[0]} …` : (lines[0] ?? "");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** macOS: `/tmp`, `/var`, `/etc` are symlinks into `/private`; paths may come either way. */
|
|
132
|
+
function unprivate(p: string): string {
|
|
133
|
+
return p.replace(/^\/private(?=\/(?:tmp|var|etc)\/)/, "");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Display path: relative to the working directory, `~` for home, otherwise as given. */
|
|
137
|
+
export function displayPath(value: unknown): string {
|
|
138
|
+
if (Array.isArray(value)) return value.map(displayPath).join(", ");
|
|
139
|
+
if (typeof value !== "string") return "";
|
|
140
|
+
const target = unprivate(value);
|
|
141
|
+
for (const dir of new Set([process.cwd(), process.env.PWD].filter(Boolean).map(d => unprivate(d!)))) {
|
|
142
|
+
if (target.startsWith(`${dir}/`)) return target.slice(dir.length + 1);
|
|
143
|
+
}
|
|
144
|
+
const home = os.homedir();
|
|
145
|
+
if (target.startsWith(`${home}/`)) return `~${target.slice(home.length)}`;
|
|
146
|
+
return value;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function plural(count: number, noun: string, pluralNoun = `${noun}s`): string {
|
|
150
|
+
return `${count} ${count === 1 ? noun : pluralNoun}`;
|
|
151
|
+
}
|