sonex-agent 0.1.0-alpha.1
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 +15 -0
- package/bin/sonex.js +235 -0
- package/dist/App.js +1360 -0
- package/dist/activity.js +18 -0
- package/dist/chat-document.js +40 -0
- package/dist/chat-message.js +93 -0
- package/dist/chat-theme.js +18 -0
- package/dist/chat-window.js +104 -0
- package/dist/command-panel.js +45 -0
- package/dist/commands.js +70 -0
- package/dist/components.js +662 -0
- package/dist/confirm-choice.js +65 -0
- package/dist/constants.js +109 -0
- package/dist/conversation-flow.js +21 -0
- package/dist/cover-pattern.js +58 -0
- package/dist/cover-visual.js +158 -0
- package/dist/extension-panel.js +136 -0
- package/dist/format.js +99 -0
- package/dist/hooks.js +216 -0
- package/dist/i18n.js +291 -0
- package/dist/index.js +46 -0
- package/dist/info-banner.js +37 -0
- package/dist/input-cursor.js +6 -0
- package/dist/input-routing.js +41 -0
- package/dist/launch-preparing.js +30 -0
- package/dist/layout.js +134 -0
- package/dist/list.js +3 -0
- package/dist/login-navigation.js +7 -0
- package/dist/mini-progress-writer.js +122 -0
- package/dist/mini-progress.js +77 -0
- package/dist/model-selection.js +20 -0
- package/dist/model-status.js +34 -0
- package/dist/mouse-input.js +173 -0
- package/dist/panel-frame.js +86 -0
- package/dist/panel-lifecycle.js +17 -0
- package/dist/playback-keymap.js +59 -0
- package/dist/provider-state.js +67 -0
- package/dist/runtime-state.js +91 -0
- package/dist/shell-state.js +37 -0
- package/dist/sonex-logo.js +9 -0
- package/dist/terminal-clear.js +10 -0
- package/dist/terminal-frame-writer.js +133 -0
- package/dist/terminal-surface.js +80 -0
- package/dist/text-stream.js +17 -0
- package/dist/track-panel.js +95 -0
- package/dist/transcript.js +92 -0
- package/dist/types.js +1 -0
- package/dist/ui-settings.js +30 -0
- package/dist/usage-animation.js +14 -0
- package/package.json +57 -0
- package/vendor/requirements-linux-py312.txt +2659 -0
- package/vendor/sonex-0.1.0a1-py3-none-any.whl +0 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
const LOCAL_PLAYBACK_SOURCES = new Set(["local", "youtube", "online"]);
|
|
2
|
+
const EXTERNAL_PLAYBACK_SOURCES = new Set(["spotify"]);
|
|
3
|
+
/**
|
|
4
|
+
* Maps raw terminal input bytes to mini-player playback shortcut actions.
|
|
5
|
+
*/
|
|
6
|
+
export function playbackShortcutFromInput(input) {
|
|
7
|
+
if (input === " ")
|
|
8
|
+
return "togglePlayback";
|
|
9
|
+
if (input === "\x1b[19~")
|
|
10
|
+
return "volumeDown";
|
|
11
|
+
if (input === "\x1b[20~")
|
|
12
|
+
return "volumeUp";
|
|
13
|
+
if (input === "\x13")
|
|
14
|
+
return "saveToPlaylist";
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Clamps a playback volume adjustment to the local player range.
|
|
19
|
+
*/
|
|
20
|
+
export function clampPlaybackVolume(currentVolume, delta) {
|
|
21
|
+
const current = typeof currentVolume === "number" && Number.isFinite(currentVolume)
|
|
22
|
+
? currentVolume
|
|
23
|
+
: 100;
|
|
24
|
+
return Math.min(100, Math.max(0, current + delta));
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Builds the existing local playback command for a parsed shortcut action.
|
|
28
|
+
*/
|
|
29
|
+
export function playbackCommandForShortcut(action, player) {
|
|
30
|
+
if (action === "togglePlayback") {
|
|
31
|
+
return player.is_playing === true ? "/pause" : "/resume";
|
|
32
|
+
}
|
|
33
|
+
if (action === "saveToPlaylist") {
|
|
34
|
+
return "/playlist save";
|
|
35
|
+
}
|
|
36
|
+
const delta = action === "volumeDown" ? -5 : 5;
|
|
37
|
+
return `/volume ${clampPlaybackVolume(player.volume_percent, delta)}`;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Returns whether the active player state belongs to local/online playback.
|
|
41
|
+
*/
|
|
42
|
+
export function isLocalPlaybackShortcutSource(player) {
|
|
43
|
+
const source = typeof player.source === "string" ? player.source.toLowerCase() : "";
|
|
44
|
+
const provider = typeof player.provider === "string" ? player.provider.toLowerCase() : "";
|
|
45
|
+
if (EXTERNAL_PLAYBACK_SOURCES.has(source) || EXTERNAL_PLAYBACK_SOURCES.has(provider)) {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
if (!source && !provider)
|
|
49
|
+
return true;
|
|
50
|
+
return LOCAL_PLAYBACK_SOURCES.has(source) || LOCAL_PLAYBACK_SOURCES.has(provider);
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Returns whether the active player state belongs to Spotify playback.
|
|
54
|
+
*/
|
|
55
|
+
export function isSpotifyPlaybackShortcutSource(player) {
|
|
56
|
+
const source = typeof player.source === "string" ? player.source.toLowerCase() : "";
|
|
57
|
+
const provider = typeof player.provider === "string" ? player.provider.toLowerCase() : "";
|
|
58
|
+
return source === "spotify" || provider === "spotify";
|
|
59
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
export const initialProviderState = {
|
|
2
|
+
spotifyMode: { enabled: false },
|
|
3
|
+
providerMode: { provider: 'normal', enabled: false },
|
|
4
|
+
spotifySetup: null,
|
|
5
|
+
authSetup: null,
|
|
6
|
+
};
|
|
7
|
+
export function reduceProviderState(state, action) {
|
|
8
|
+
if (action.type === 'replace')
|
|
9
|
+
return action.state;
|
|
10
|
+
if (action.type === 'clear_spotify_setup')
|
|
11
|
+
return { ...state, spotifySetup: null };
|
|
12
|
+
if (action.type === 'clear_auth_setup')
|
|
13
|
+
return { ...state, authSetup: null };
|
|
14
|
+
const event = action.event;
|
|
15
|
+
switch (event.type) {
|
|
16
|
+
case 'spotify_mode':
|
|
17
|
+
return {
|
|
18
|
+
...state,
|
|
19
|
+
spotifyMode: {
|
|
20
|
+
enabled: event.enabled,
|
|
21
|
+
device_id: event.device_id,
|
|
22
|
+
device_name: event.device_name,
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
case 'provider_mode':
|
|
26
|
+
return {
|
|
27
|
+
...state,
|
|
28
|
+
providerMode: {
|
|
29
|
+
provider: event.provider,
|
|
30
|
+
enabled: event.enabled,
|
|
31
|
+
connection_status: event.connection_status,
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
case 'spotify_setup':
|
|
35
|
+
return {
|
|
36
|
+
...state,
|
|
37
|
+
spotifySetup: {
|
|
38
|
+
step: event.step,
|
|
39
|
+
title: event.title,
|
|
40
|
+
message: event.message,
|
|
41
|
+
prompt: event.prompt,
|
|
42
|
+
mask: event.mask,
|
|
43
|
+
active: event.active !== false,
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
case 'auth_setup':
|
|
47
|
+
return {
|
|
48
|
+
...state,
|
|
49
|
+
authSetup: event.active === false && event.step === 'model'
|
|
50
|
+
? null
|
|
51
|
+
: {
|
|
52
|
+
provider: event.provider,
|
|
53
|
+
step: event.step,
|
|
54
|
+
title: event.title,
|
|
55
|
+
message: event.message,
|
|
56
|
+
prompt: event.prompt,
|
|
57
|
+
placeholder: event.placeholder,
|
|
58
|
+
help_text: event.help_text,
|
|
59
|
+
mask: event.mask,
|
|
60
|
+
active: event.active !== false,
|
|
61
|
+
methods: event.methods,
|
|
62
|
+
providers: event.providers,
|
|
63
|
+
models: event.models,
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { upsertActivity } from './activity.js';
|
|
2
|
+
import { shouldStartLaunchPreparing } from './launch-preparing.js';
|
|
3
|
+
export const createInitialRuntimeState = (statusText) => ({
|
|
4
|
+
sessionId: null,
|
|
5
|
+
tokenUsage: { inputTokens: 0, outputTokens: 0 },
|
|
6
|
+
agentWorkingTurnId: null,
|
|
7
|
+
activityItems: [],
|
|
8
|
+
statusText,
|
|
9
|
+
launchPreparing: false,
|
|
10
|
+
recommendInputLocked: false,
|
|
11
|
+
});
|
|
12
|
+
const clearsLaunchPreparing = new Set([
|
|
13
|
+
'track_panel',
|
|
14
|
+
'memory_panel',
|
|
15
|
+
'extension_panel',
|
|
16
|
+
'player',
|
|
17
|
+
'confirm',
|
|
18
|
+
'spotify_setup',
|
|
19
|
+
'auth_setup',
|
|
20
|
+
'help_panel',
|
|
21
|
+
'bye',
|
|
22
|
+
]);
|
|
23
|
+
export function reduceRuntimeState(state, action) {
|
|
24
|
+
if (action.type === 'replace')
|
|
25
|
+
return action.state;
|
|
26
|
+
if (action.type === 'set_status')
|
|
27
|
+
return { ...state, statusText: action.text };
|
|
28
|
+
if (action.type === 'clear_agent_working')
|
|
29
|
+
return { ...state, agentWorkingTurnId: null };
|
|
30
|
+
const event = action.event;
|
|
31
|
+
const next = clearsLaunchPreparing.has(event.type)
|
|
32
|
+
? { ...state, launchPreparing: false }
|
|
33
|
+
: state;
|
|
34
|
+
switch (event.type) {
|
|
35
|
+
case 'session_state':
|
|
36
|
+
return { ...next, sessionId: event.session_id };
|
|
37
|
+
case 'usage_state':
|
|
38
|
+
return {
|
|
39
|
+
...next,
|
|
40
|
+
tokenUsage: {
|
|
41
|
+
inputTokens: event.input_tokens,
|
|
42
|
+
outputTokens: event.output_tokens,
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
case 'agent_working_state':
|
|
46
|
+
return {
|
|
47
|
+
...next,
|
|
48
|
+
agentWorkingTurnId: event.active
|
|
49
|
+
? event.turn_id
|
|
50
|
+
: next.agentWorkingTurnId === event.turn_id
|
|
51
|
+
? null
|
|
52
|
+
: next.agentWorkingTurnId,
|
|
53
|
+
};
|
|
54
|
+
case 'activity': {
|
|
55
|
+
const launchPreparing = shouldStartLaunchPreparing(event)
|
|
56
|
+
? true
|
|
57
|
+
: event.status === 'success' || event.status === 'error'
|
|
58
|
+
? false
|
|
59
|
+
: next.launchPreparing;
|
|
60
|
+
return {
|
|
61
|
+
...next,
|
|
62
|
+
activityItems: upsertActivity(next.activityItems, event),
|
|
63
|
+
launchPreparing,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
case 'status':
|
|
67
|
+
return {
|
|
68
|
+
...next,
|
|
69
|
+
statusText: event.message,
|
|
70
|
+
launchPreparing: action.rawEvent?.type === 'status'
|
|
71
|
+
? action.rawEvent.active !== false && action.rawEvent.message === 'Preparing playback...'
|
|
72
|
+
: event.active !== false && event.message === 'Preparing playback...',
|
|
73
|
+
};
|
|
74
|
+
case 'input_state':
|
|
75
|
+
return {
|
|
76
|
+
...next,
|
|
77
|
+
recommendInputLocked: event.disabled && event.reason === 'recommendation',
|
|
78
|
+
};
|
|
79
|
+
case 'track_panel':
|
|
80
|
+
case 'memory_panel':
|
|
81
|
+
case 'extension_panel':
|
|
82
|
+
case 'spotify_setup':
|
|
83
|
+
case 'auth_setup':
|
|
84
|
+
case 'help_panel':
|
|
85
|
+
return { ...next, statusText: event.title };
|
|
86
|
+
case 'bye':
|
|
87
|
+
return { ...next, statusText: event.message ?? `Session saved to ${event.path}. Bye.` };
|
|
88
|
+
default:
|
|
89
|
+
return next;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { resolveRegionAfterPlayerEvent, toggleShellRegion } from './layout.js';
|
|
2
|
+
export const initialShellState = {
|
|
3
|
+
region: 'chat',
|
|
4
|
+
playbackSessionActive: false,
|
|
5
|
+
};
|
|
6
|
+
export function surfaceForShellRegion(region) {
|
|
7
|
+
return region === 'chat' || region === 'memoryPanel' ? 'main' : 'alternate';
|
|
8
|
+
}
|
|
9
|
+
export function planShellSurfaceTransition(currentRegion, nextRegion) {
|
|
10
|
+
return {
|
|
11
|
+
changed: currentRegion !== nextRegion,
|
|
12
|
+
target: surfaceForShellRegion(nextRegion),
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
export function reduceShellState(state, action) {
|
|
16
|
+
if (action.type === 'replace')
|
|
17
|
+
return action.state;
|
|
18
|
+
if (action.type === 'set_region')
|
|
19
|
+
return { ...state, region: action.region };
|
|
20
|
+
if (action.type === 'player_event') {
|
|
21
|
+
const transition = resolveRegionAfterPlayerEvent({
|
|
22
|
+
currentRegion: state.region,
|
|
23
|
+
wasSessionActive: state.playbackSessionActive,
|
|
24
|
+
player: action.player,
|
|
25
|
+
spotifyModeEnabled: action.spotifyModeEnabled,
|
|
26
|
+
providerMode: action.providerMode,
|
|
27
|
+
});
|
|
28
|
+
return {
|
|
29
|
+
region: transition.region,
|
|
30
|
+
playbackSessionActive: transition.sessionActive,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
return {
|
|
34
|
+
...state,
|
|
35
|
+
region: toggleShellRegion(state.region, state.playbackSessionActive, action.spotifyModeEnabled, action.providerModeEnabled),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export const SONEX_LOGO = [
|
|
2
|
+
"███████╗ ██████╗ ███╗ ██╗███████╗██╗ ██╗",
|
|
3
|
+
"██╔════╝██╔═══██╗████╗ ██║██╔════╝╚██╗██╔╝",
|
|
4
|
+
"███████╗██║ ██║██╔██╗ ██║█████╗ ╚███╔╝ ",
|
|
5
|
+
"╚════██║██║ ██║██║╚██╗██║██╔══╝ ██╔██╗ ",
|
|
6
|
+
"███████║╚██████╔╝██║ ╚████║███████╗██╔╝ ██╗",
|
|
7
|
+
"╚══════╝ ╚═════╝ ╚═╝ ╚═══╝╚══════╝╚═╝ ╚═╝",
|
|
8
|
+
];
|
|
9
|
+
export const SONEX_LOGO_WIDTH = 43;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
const CLEAR_SCREEN_AND_HOME = '\u001B[2J\u001B[H';
|
|
2
|
+
/**
|
|
3
|
+
* Coordinates the clear terminal for layout switch operation for the CLI UI runtime.
|
|
4
|
+
*
|
|
5
|
+
* @param stdout Input value used by the clear terminal for layout switch operation.
|
|
6
|
+
* @returns The computed result for the surrounding CLI UI flow.
|
|
7
|
+
*/
|
|
8
|
+
export function clearTerminalForLayoutSwitch(stdout) {
|
|
9
|
+
stdout.write(CLEAR_SCREEN_AND_HOME);
|
|
10
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
export const APP_CLEAR_SCREEN = '\u001B[2J\u001B[H';
|
|
2
|
+
export const INK_CLEAR_SCREEN = '\u001B[2J\u001B[3J\u001B[H';
|
|
3
|
+
const SAVE_CURSOR = '\u001B7';
|
|
4
|
+
const RESTORE_CURSOR = '\u001B8';
|
|
5
|
+
const ERASE_LINE = '\u001B[2K';
|
|
6
|
+
const TRAILING_ROW_BACKGROUND_MARKER = /\u001B\]777;sonex-fill-trailing-row=([0-9a-fA-F]{6})\u0007/;
|
|
7
|
+
const TERMINAL_CONTROL_SEQUENCE = /\u001B(?:\][^\u0007]*(?:\u0007|\u001B\\)|\[[0-?]*[ -/]*[@-~]|[()][0-2A-Z]|[=>78])/g;
|
|
8
|
+
const normalizeTrueColor = (color) => (/^#?([0-9a-fA-F]{6})$/.exec(color)?.[1] ?? "000000");
|
|
9
|
+
export const withTrueColorBackground = (value, color) => {
|
|
10
|
+
const normalized = normalizeTrueColor(color);
|
|
11
|
+
const red = Number.parseInt(normalized.slice(0, 2), 16);
|
|
12
|
+
const green = Number.parseInt(normalized.slice(2, 4), 16);
|
|
13
|
+
const blue = Number.parseInt(normalized.slice(4, 6), 16);
|
|
14
|
+
return `\u001B[48;2;${red};${green};${blue}m${value}\u001B[49m`;
|
|
15
|
+
};
|
|
16
|
+
export const trailingRowBackgroundMarker = (color) => {
|
|
17
|
+
const normalized = normalizeTrueColor(color);
|
|
18
|
+
return `\u001B]777;sonex-fill-trailing-row=${normalized}\u0007`;
|
|
19
|
+
};
|
|
20
|
+
const fillTrailingRowBackground = (chunk, columns) => {
|
|
21
|
+
const color = TRAILING_ROW_BACKGROUND_MARKER.exec(chunk)?.[1];
|
|
22
|
+
const output = chunk.replace(TRAILING_ROW_BACKGROUND_MARKER, "");
|
|
23
|
+
const width = Math.max(0, Math.floor(columns ?? 0));
|
|
24
|
+
if (!color || width === 0 || !output.endsWith("\n"))
|
|
25
|
+
return output;
|
|
26
|
+
return `${output}${withTrueColorBackground(" ".repeat(width), color)}\r`;
|
|
27
|
+
};
|
|
28
|
+
const sameDimensions = (left, right) => (left?.columns === right.columns && left?.rows === right.rows);
|
|
29
|
+
const changedRowsOutput = (previousRows, nextRows) => {
|
|
30
|
+
let output = "";
|
|
31
|
+
for (let index = 0; index < nextRows.length; index += 1) {
|
|
32
|
+
if (previousRows[index] === nextRows[index])
|
|
33
|
+
continue;
|
|
34
|
+
output += `\u001B[${index + 1};1H${ERASE_LINE}${nextRows[index]}`;
|
|
35
|
+
}
|
|
36
|
+
return output ? `${SAVE_CURSOR}${output}${RESTORE_CURSOR}` : "";
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* Converts Ink's repeated full-screen frames into row-level terminal updates.
|
|
40
|
+
*
|
|
41
|
+
* Ink clears the entire terminal whenever rendered output fills its height.
|
|
42
|
+
* Sonex intentionally fills the terminal so its input dock remains anchored at
|
|
43
|
+
* the bottom, which otherwise turns every cursor blink into a full repaint.
|
|
44
|
+
*/
|
|
45
|
+
export class IncrementalTerminalFrameWriter {
|
|
46
|
+
getDimensions;
|
|
47
|
+
previousRows = null;
|
|
48
|
+
previousDimensions = null;
|
|
49
|
+
constructor(getDimensions) {
|
|
50
|
+
this.getDimensions = getDimensions;
|
|
51
|
+
}
|
|
52
|
+
transform(chunk) {
|
|
53
|
+
if (chunk.startsWith(APP_CLEAR_SCREEN)) {
|
|
54
|
+
this.reset();
|
|
55
|
+
return chunk;
|
|
56
|
+
}
|
|
57
|
+
if (!chunk.startsWith(INK_CLEAR_SCREEN)) {
|
|
58
|
+
return chunk;
|
|
59
|
+
}
|
|
60
|
+
const frame = chunk.slice(INK_CLEAR_SCREEN.length);
|
|
61
|
+
if (!frame) {
|
|
62
|
+
this.reset();
|
|
63
|
+
return chunk;
|
|
64
|
+
}
|
|
65
|
+
const nextRows = frame.split("\n");
|
|
66
|
+
const nextDimensions = this.getDimensions();
|
|
67
|
+
const previousRows = this.previousRows;
|
|
68
|
+
const canUpdateIncrementally = (previousRows !== null
|
|
69
|
+
&& previousRows.length === nextRows.length
|
|
70
|
+
&& sameDimensions(this.previousDimensions, nextDimensions));
|
|
71
|
+
if (!canUpdateIncrementally) {
|
|
72
|
+
this.previousRows = nextRows;
|
|
73
|
+
this.previousDimensions = nextDimensions;
|
|
74
|
+
return chunk;
|
|
75
|
+
}
|
|
76
|
+
const output = changedRowsOutput(previousRows, nextRows);
|
|
77
|
+
this.previousRows = nextRows;
|
|
78
|
+
this.previousDimensions = nextDimensions;
|
|
79
|
+
return output || null;
|
|
80
|
+
}
|
|
81
|
+
reset() {
|
|
82
|
+
this.previousRows = null;
|
|
83
|
+
this.previousDimensions = null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const completeSuppressedWrite = (args) => {
|
|
87
|
+
const callback = args.at(-1);
|
|
88
|
+
if (typeof callback === "function") {
|
|
89
|
+
queueMicrotask(() => callback());
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
export const stripTerminalControlSequences = (value) => (value.replace(TERMINAL_CONTROL_SEQUENCE, ""));
|
|
93
|
+
/**
|
|
94
|
+
* Wraps stdout while preserving its width, events, and direct-write behavior.
|
|
95
|
+
*
|
|
96
|
+
* Ink treats any frame at least as tall as `stdout.rows` as a full-screen app
|
|
97
|
+
* and clears both the visible buffer and scrollback. The renderer-facing proxy
|
|
98
|
+
* intentionally hides `rows`; App receives the real stream separately for
|
|
99
|
+
* layout, resize handling, and direct alternate-screen writers.
|
|
100
|
+
*/
|
|
101
|
+
export const createIncrementalStdout = (stdout) => {
|
|
102
|
+
const frameWriter = new IncrementalTerminalFrameWriter(() => ({
|
|
103
|
+
columns: stdout.columns,
|
|
104
|
+
rows: stdout.rows,
|
|
105
|
+
}));
|
|
106
|
+
const writeThrough = stdout.write.bind(stdout);
|
|
107
|
+
const incrementalWrite = ((chunk, ...args) => {
|
|
108
|
+
if (typeof chunk !== "string") {
|
|
109
|
+
return writeThrough(chunk, ...args);
|
|
110
|
+
}
|
|
111
|
+
const safeChunk = stdout.isTTY === true
|
|
112
|
+
? fillTrailingRowBackground(chunk, stdout.columns)
|
|
113
|
+
: stripTerminalControlSequences(chunk);
|
|
114
|
+
const output = frameWriter.transform(safeChunk);
|
|
115
|
+
if (output === null) {
|
|
116
|
+
completeSuppressedWrite(args);
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
return writeThrough(output, ...args);
|
|
120
|
+
});
|
|
121
|
+
return new Proxy(stdout, {
|
|
122
|
+
get(target, property) {
|
|
123
|
+
if (property === "write")
|
|
124
|
+
return incrementalWrite;
|
|
125
|
+
if (property === "reset")
|
|
126
|
+
return () => frameWriter.reset();
|
|
127
|
+
if (property === "rows")
|
|
128
|
+
return undefined;
|
|
129
|
+
const value = Reflect.get(target, property, target);
|
|
130
|
+
return typeof value === "function" ? value.bind(target) : value;
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
};
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
export const ALT_SCREEN_ENTER = '\u001B[?1049h\u001B[2J\u001B[H';
|
|
2
|
+
export const ALT_SCREEN_CLEAR = '\u001B[2J\u001B[H';
|
|
3
|
+
export const ALT_SCREEN_LEAVE = '\u001B[?1049l';
|
|
4
|
+
export const MOUSE_TRACKING_DISABLE = '\u001B[?1006l\u001B[?1000l';
|
|
5
|
+
export const CURSOR_SHOW = '\u001B[?25h';
|
|
6
|
+
export class TerminalSurfaceController {
|
|
7
|
+
options;
|
|
8
|
+
surface = 'main';
|
|
9
|
+
rendererClear = () => undefined;
|
|
10
|
+
disposed = false;
|
|
11
|
+
constructor(options) {
|
|
12
|
+
this.options = options;
|
|
13
|
+
}
|
|
14
|
+
attachRendererClear(clear) {
|
|
15
|
+
this.rendererClear = clear;
|
|
16
|
+
}
|
|
17
|
+
prepare() {
|
|
18
|
+
if (this.options.isTTY) {
|
|
19
|
+
this.options.write(MOUSE_TRACKING_DISABLE);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
transition(next, commit) {
|
|
23
|
+
if (this.disposed)
|
|
24
|
+
return;
|
|
25
|
+
if (!this.options.isTTY) {
|
|
26
|
+
this.surface = 'main';
|
|
27
|
+
commit('main');
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
if (next === this.surface && next === 'main') {
|
|
31
|
+
commit('main');
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
try {
|
|
35
|
+
this.rendererClear();
|
|
36
|
+
this.options.resetFrame();
|
|
37
|
+
if (next === 'alternate' && this.surface === 'main') {
|
|
38
|
+
this.surface = 'alternate';
|
|
39
|
+
this.options.write(ALT_SCREEN_ENTER);
|
|
40
|
+
}
|
|
41
|
+
else if (next === 'alternate') {
|
|
42
|
+
this.options.write(ALT_SCREEN_CLEAR);
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
this.options.write(ALT_SCREEN_LEAVE);
|
|
46
|
+
this.surface = 'main';
|
|
47
|
+
}
|
|
48
|
+
commit(this.surface);
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
this.dispose();
|
|
52
|
+
throw error;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
dispose() {
|
|
56
|
+
if (this.disposed)
|
|
57
|
+
return;
|
|
58
|
+
this.disposed = true;
|
|
59
|
+
if (!this.options.isTTY) {
|
|
60
|
+
this.bestEffort(this.options.resetFrame);
|
|
61
|
+
this.surface = 'main';
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
this.bestEffort(this.rendererClear);
|
|
65
|
+
this.bestEffort(this.options.resetFrame);
|
|
66
|
+
const leave = this.surface === 'alternate' ? ALT_SCREEN_LEAVE : '';
|
|
67
|
+
this.bestEffort(() => {
|
|
68
|
+
this.options.write(`${leave}${MOUSE_TRACKING_DISABLE}${CURSOR_SHOW}`);
|
|
69
|
+
});
|
|
70
|
+
this.surface = 'main';
|
|
71
|
+
}
|
|
72
|
+
bestEffort(operation) {
|
|
73
|
+
try {
|
|
74
|
+
operation();
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
// Terminal restoration must continue even if one cleanup step fails.
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export const TEXT_STREAM_INTERVAL_MS = 16;
|
|
2
|
+
export const TEXT_STREAM_TARGET_FRAMES = 60;
|
|
3
|
+
export const textStreamUnits = (text) => Array.from(text);
|
|
4
|
+
export const nextTextStreamOffset = (current, total) => {
|
|
5
|
+
const normalizedTotal = Math.max(0, Math.floor(total));
|
|
6
|
+
const normalizedCurrent = Math.min(normalizedTotal, Math.max(0, Math.floor(current)));
|
|
7
|
+
if (normalizedCurrent >= normalizedTotal)
|
|
8
|
+
return normalizedTotal;
|
|
9
|
+
const chunkSize = Math.max(1, Math.ceil(normalizedTotal / TEXT_STREAM_TARGET_FRAMES));
|
|
10
|
+
return Math.min(normalizedTotal, normalizedCurrent + chunkSize);
|
|
11
|
+
};
|
|
12
|
+
export const streamedChatMessage = (item, units, visibleUnitCount) => ({
|
|
13
|
+
...item,
|
|
14
|
+
content: units.slice(0, Math.max(0, visibleUnitCount)).join(''),
|
|
15
|
+
segments: undefined,
|
|
16
|
+
document: undefined,
|
|
17
|
+
});
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import stringWidth from 'string-width';
|
|
2
|
+
const textValue = (value) => String(value ?? "").trim();
|
|
3
|
+
const TRACK_PANEL_INDEX_WIDTH = 3;
|
|
4
|
+
const TRACK_PANEL_ARTIST_WIDTH = 24;
|
|
5
|
+
const ELLIPSIS = "...";
|
|
6
|
+
const codepoints = (value) => Array.from(value);
|
|
7
|
+
function fitDisplayWidthWithEllipsis(value, width) {
|
|
8
|
+
const normalized = textValue(value) || "-";
|
|
9
|
+
if (stringWidth(normalized) <= width) {
|
|
10
|
+
return normalized + " ".repeat(Math.max(0, width - stringWidth(normalized)));
|
|
11
|
+
}
|
|
12
|
+
const contentWidth = Math.max(0, width - stringWidth(ELLIPSIS));
|
|
13
|
+
let rendered = "";
|
|
14
|
+
let renderedWidth = 0;
|
|
15
|
+
for (const char of codepoints(normalized)) {
|
|
16
|
+
const charWidth = stringWidth(char);
|
|
17
|
+
if (renderedWidth + charWidth > contentWidth)
|
|
18
|
+
break;
|
|
19
|
+
rendered += char;
|
|
20
|
+
renderedWidth += charWidth;
|
|
21
|
+
}
|
|
22
|
+
const result = `${rendered}${ELLIPSIS}`;
|
|
23
|
+
return result + " ".repeat(Math.max(0, width - stringWidth(result)));
|
|
24
|
+
}
|
|
25
|
+
function truncateDisplayWidthWithEllipsis(value, width) {
|
|
26
|
+
const normalized = textValue(value) || "-";
|
|
27
|
+
if (width <= 0)
|
|
28
|
+
return "";
|
|
29
|
+
if (stringWidth(normalized) <= width)
|
|
30
|
+
return normalized;
|
|
31
|
+
const contentWidth = Math.max(0, width - stringWidth(ELLIPSIS));
|
|
32
|
+
let rendered = "";
|
|
33
|
+
let renderedWidth = 0;
|
|
34
|
+
for (const char of codepoints(normalized)) {
|
|
35
|
+
const charWidth = stringWidth(char);
|
|
36
|
+
if (renderedWidth + charWidth > contentWidth)
|
|
37
|
+
break;
|
|
38
|
+
rendered += char;
|
|
39
|
+
renderedWidth += charWidth;
|
|
40
|
+
}
|
|
41
|
+
return `${rendered}${ELLIPSIS}`;
|
|
42
|
+
}
|
|
43
|
+
function padStartDisplayWidth(value, width) {
|
|
44
|
+
const normalized = textValue(value) || "-";
|
|
45
|
+
const renderedWidth = stringWidth(normalized);
|
|
46
|
+
return " ".repeat(Math.max(0, width - renderedWidth)) + normalized;
|
|
47
|
+
}
|
|
48
|
+
export function formatTrackPanelIndex(track) {
|
|
49
|
+
const index = padStartDisplayWidth(track.index, TRACK_PANEL_INDEX_WIDTH);
|
|
50
|
+
return track.queued ? `✓${index}` : ` ${index}`;
|
|
51
|
+
}
|
|
52
|
+
export function formatTrackPanelLine(track, rowWidth) {
|
|
53
|
+
const prefix = [
|
|
54
|
+
formatTrackPanelIndex(track),
|
|
55
|
+
fitDisplayWidthWithEllipsis(track.artist, TRACK_PANEL_ARTIST_WIDTH),
|
|
56
|
+
].join(" ");
|
|
57
|
+
const titleWidth = Math.max(0, rowWidth - stringWidth(prefix) - 1);
|
|
58
|
+
return `${prefix} ${truncateDisplayWidthWithEllipsis(track.title, titleWidth)}`;
|
|
59
|
+
}
|
|
60
|
+
export function trackPanelTrackKey(track) {
|
|
61
|
+
for (const field of [
|
|
62
|
+
"cache_id",
|
|
63
|
+
"uri",
|
|
64
|
+
"spotify_url",
|
|
65
|
+
"requires_resolution",
|
|
66
|
+
"youtube_url",
|
|
67
|
+
"url",
|
|
68
|
+
"stream_url",
|
|
69
|
+
"audio_path",
|
|
70
|
+
"file_path",
|
|
71
|
+
"path",
|
|
72
|
+
"id",
|
|
73
|
+
]) {
|
|
74
|
+
const value = textValue(track[field]);
|
|
75
|
+
if (value)
|
|
76
|
+
return `${field}:${value}`;
|
|
77
|
+
}
|
|
78
|
+
const name = textValue(track.name || track.title);
|
|
79
|
+
const artist = textValue(track.artist);
|
|
80
|
+
const album = textValue(track.album);
|
|
81
|
+
const duration = Number(track.duration_ms || 0);
|
|
82
|
+
if (!name)
|
|
83
|
+
return "";
|
|
84
|
+
if (artist || album || duration || textValue(track.provider || track.source) === "local") {
|
|
85
|
+
return `text:${name.toLowerCase()}|${artist.toLowerCase()}|${album.toLowerCase()}|${duration}`;
|
|
86
|
+
}
|
|
87
|
+
return "";
|
|
88
|
+
}
|
|
89
|
+
export function markQueuedTracks(tracks, queuedTracks) {
|
|
90
|
+
const queuedKeys = new Set(queuedTracks.map(trackPanelTrackKey).filter(Boolean));
|
|
91
|
+
return tracks.map((track) => {
|
|
92
|
+
const key = trackPanelTrackKey(track);
|
|
93
|
+
return { ...track, queued: Boolean(key && queuedKeys.has(key)) };
|
|
94
|
+
});
|
|
95
|
+
}
|