2025-09-22 21:53:13 +02:00
|
|
|
import whisky
|
2025-09-14 22:55:44 +02:00
|
|
|
import strformat, strutils, times
|
2025-09-10 18:25:15 +02:00
|
|
|
import imguin/[cimgui, glfw_opengl, simple]
|
2025-09-18 12:35:26 +02:00
|
|
|
import ../utils/[appImGui, colors]
|
2025-09-10 18:25:15 +02:00
|
|
|
import ../../common/[types]
|
2025-09-27 17:45:52 +02:00
|
|
|
import ../websocket
|
2025-09-10 18:25:15 +02:00
|
|
|
|
2025-09-16 20:17:48 +02:00
|
|
|
const MAX_INPUT_LENGTH = 512
|
|
|
|
|
type
|
2025-09-10 18:25:15 +02:00
|
|
|
ConsoleComponent* = ref object of RootObj
|
2025-09-25 19:22:17 +02:00
|
|
|
agent*: UIAgent
|
2025-09-10 18:25:15 +02:00
|
|
|
showConsole*: bool
|
2025-09-16 20:17:48 +02:00
|
|
|
inputBuffer: array[MAX_INPUT_LENGTH, char]
|
2025-09-16 22:21:11 +02:00
|
|
|
console*: ConsoleItems
|
|
|
|
|
history: seq[string]
|
|
|
|
|
historyPosition: int
|
|
|
|
|
currentInput: string
|
2025-09-14 22:55:44 +02:00
|
|
|
textSelect: ptr TextSelect
|
2025-09-18 12:35:26 +02:00
|
|
|
filter: ptr ImGuiTextFilter
|
2025-09-14 22:55:44 +02:00
|
|
|
|
2025-09-16 22:21:11 +02:00
|
|
|
#[
|
|
|
|
|
Helper functions for text selection
|
|
|
|
|
]#
|
2025-09-18 12:35:26 +02:00
|
|
|
proc getText(item: ConsoleItem): cstring =
|
|
|
|
|
if item.timestamp > 0:
|
|
|
|
|
let timestamp = item.timestamp.fromUnix().format("dd-MM-yyyy HH:mm:ss")
|
|
|
|
|
return fmt"[{timestamp}]{$item.itemType}{item.text}".string
|
|
|
|
|
else:
|
|
|
|
|
return fmt"{$item.itemType}{item.text}".string
|
|
|
|
|
|
2025-09-14 22:55:44 +02:00
|
|
|
proc getNumLines(data: pointer): csize_t {.cdecl.} =
|
|
|
|
|
if data.isNil:
|
|
|
|
|
return 0
|
2025-09-16 20:17:48 +02:00
|
|
|
let console = cast[ConsoleItems](data)
|
|
|
|
|
return console.items.len().csize_t
|
2025-09-14 22:55:44 +02:00
|
|
|
|
|
|
|
|
proc getLineAtIndex(i: csize_t, data: pointer, outLen: ptr csize_t): cstring {.cdecl.} =
|
|
|
|
|
if data.isNil:
|
|
|
|
|
return nil
|
2025-09-16 20:17:48 +02:00
|
|
|
let console = cast[ConsoleItems](data)
|
2025-09-18 12:35:26 +02:00
|
|
|
let line = console.items[i].getText()
|
2025-09-14 22:55:44 +02:00
|
|
|
if not outLen.isNil:
|
|
|
|
|
outLen[] = line.len.csize_t
|
|
|
|
|
return line
|
|
|
|
|
|
2025-09-25 19:22:17 +02:00
|
|
|
proc Console*(agent: UIAgent): ConsoleComponent =
|
2025-09-10 18:25:15 +02:00
|
|
|
result = new ConsoleComponent
|
|
|
|
|
result.agent = agent
|
|
|
|
|
result.showConsole = true
|
2025-09-16 20:17:48 +02:00
|
|
|
zeroMem(addr result.inputBuffer[0], MAX_INPUT_LENGTH)
|
|
|
|
|
result.console = new ConsoleItems
|
2025-09-16 22:21:11 +02:00
|
|
|
result.console.items = @[]
|
|
|
|
|
result.history = @[]
|
|
|
|
|
result.historyPosition = -1
|
|
|
|
|
result.currentInput = ""
|
2025-09-16 20:17:48 +02:00
|
|
|
result.textSelect = textselect_create(getLineAtIndex, getNumLines, cast[pointer](result.console), 0)
|
2025-09-18 12:35:26 +02:00
|
|
|
result.filter = ImGuiTextFilter_ImGuiTextFilter("")
|
2025-09-10 18:25:15 +02:00
|
|
|
|
2025-09-16 22:21:11 +02:00
|
|
|
#[
|
|
|
|
|
Text input callback function for managing console history and autocompletion
|
|
|
|
|
]#
|
|
|
|
|
proc callback(data: ptr ImGuiInputTextCallbackData): cint {.cdecl.} =
|
|
|
|
|
|
|
|
|
|
let component = cast[ConsoleComponent](data.UserData)
|
|
|
|
|
|
|
|
|
|
case data.EventFlag:
|
|
|
|
|
of ImGui_InputTextFlags_CallbackHistory.int32:
|
|
|
|
|
# Handle command history using arrow-keys
|
|
|
|
|
|
|
|
|
|
# Store current input
|
|
|
|
|
if component.historyPosition == -1:
|
|
|
|
|
component.currentInput = $(data.Buf)
|
|
|
|
|
|
|
|
|
|
let prev = component.historyPosition
|
|
|
|
|
|
|
|
|
|
# Move to a new console history item
|
|
|
|
|
if data.EventKey == ImGuiKey_UpArrow:
|
|
|
|
|
if component.history.len() > 0:
|
|
|
|
|
if component.historyPosition < 0: # We are at the current input and move to the last item in the console history
|
|
|
|
|
component.historyPosition = component.history.len() - 1
|
|
|
|
|
else:
|
|
|
|
|
component.historyPosition = max(0, component.historyPosition - 1)
|
|
|
|
|
|
|
|
|
|
elif data.EventKey == ImGuiKey_DownArrow:
|
|
|
|
|
if component.historyPosition != -1:
|
|
|
|
|
component.historyPosition = min(component.history.len(), component.historyPosition + 1)
|
|
|
|
|
|
|
|
|
|
if component.historyPosition == component.history.len():
|
|
|
|
|
component.historyPosition = -1
|
|
|
|
|
|
|
|
|
|
# Update the text buffer if another item was selected
|
|
|
|
|
if prev != component.historyPosition:
|
|
|
|
|
let newText = if component.historyPosition == -1:
|
|
|
|
|
component.currentInput
|
|
|
|
|
else:
|
|
|
|
|
component.history[component.historyPosition]
|
|
|
|
|
|
|
|
|
|
# Replace text input
|
|
|
|
|
data.ImGuiInputTextCallbackData_DeleteChars(0, data.BufTextLen)
|
|
|
|
|
data.ImGuiInputTextCallbackData_InsertChars(0, newText.cstring, nil)
|
|
|
|
|
|
|
|
|
|
# Set the cursor to the end of the updated input text
|
|
|
|
|
data.CursorPos = newText.len().cint
|
|
|
|
|
data.SelectionStart = newText.len().cint
|
|
|
|
|
data.SelectionEnd = newText.len().cint
|
|
|
|
|
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
of ImGui_InputTextFlags_CallbackCompletion.int32:
|
|
|
|
|
# Handle Tab-autocompletion
|
|
|
|
|
discard
|
|
|
|
|
|
|
|
|
|
else: discard
|
2025-09-19 11:43:14 +02:00
|
|
|
|
|
|
|
|
#[
|
|
|
|
|
API to add new console item
|
|
|
|
|
]#
|
2025-09-22 21:53:13 +02:00
|
|
|
proc addItem*(component: ConsoleComponent, itemType: LogType, data: string, timestamp: int64 = now().toTime().toUnix()) =
|
2025-09-19 11:43:14 +02:00
|
|
|
|
|
|
|
|
for line in data.split("\n"):
|
|
|
|
|
component.console.items.add(ConsoleItem(
|
2025-09-22 21:53:13 +02:00
|
|
|
timestamp: if itemType == LOG_OUTPUT: 0 else: timestamp,
|
2025-09-19 11:43:14 +02:00
|
|
|
itemType: itemType,
|
|
|
|
|
text: line
|
|
|
|
|
))
|
|
|
|
|
|
|
|
|
|
#[
|
|
|
|
|
Drawing
|
|
|
|
|
]#
|
|
|
|
|
proc print(item: ConsoleItem) =
|
|
|
|
|
if item.timestamp > 0:
|
|
|
|
|
let timestamp = item.timestamp.fromUnix().format("dd-MM-yyyy HH:mm:ss")
|
|
|
|
|
igTextColored(vec4(0.6f, 0.6f, 0.6f, 1.0f), fmt"[{timestamp}]".cstring)
|
|
|
|
|
igSameLine(0.0f, 0.0f)
|
2025-09-16 22:21:11 +02:00
|
|
|
|
2025-09-19 11:43:14 +02:00
|
|
|
case item.itemType:
|
|
|
|
|
of LOG_INFO, LOG_INFO_SHORT:
|
|
|
|
|
igTextColored(CONSOLE_INFO, $item.itemType)
|
|
|
|
|
of LOG_ERROR, LOG_ERROR_SHORT:
|
|
|
|
|
igTextColored(CONSOLE_ERROR, $item.itemType)
|
|
|
|
|
of LOG_SUCCESS, LOG_SUCCESS_SHORT:
|
|
|
|
|
igTextColored(CONSOLE_SUCCESS, $item.itemType)
|
|
|
|
|
of LOG_WARNING, LOG_WARNING_SHORT:
|
|
|
|
|
igTextColored(CONSOLE_WARNING, $item.itemType)
|
|
|
|
|
of LOG_COMMAND:
|
|
|
|
|
igTextColored(CONSOLE_COMMAND, $item.itemType)
|
|
|
|
|
of LOG_OUTPUT:
|
|
|
|
|
igTextColored(vec4(0.0f, 0.0f, 0.0f, 0.0f), $item.itemType)
|
|
|
|
|
|
|
|
|
|
igSameLine(0.0f, 0.0f)
|
|
|
|
|
igTextUnformatted(item.text.cstring, nil)
|
|
|
|
|
|
2025-09-22 21:53:13 +02:00
|
|
|
proc draw*(component: ConsoleComponent, ws: WebSocket) =
|
2025-09-25 20:22:56 +02:00
|
|
|
igBegin(fmt"[{component.agent.agentId}] {component.agent.username}@{component.agent.hostname}".cstring, addr component.showConsole, 0)
|
2025-09-14 22:55:44 +02:00
|
|
|
defer: igEnd()
|
|
|
|
|
|
2025-09-18 12:35:26 +02:00
|
|
|
let io = igGetIO()
|
|
|
|
|
|
2025-09-16 20:17:48 +02:00
|
|
|
var focusInput = false
|
|
|
|
|
|
2025-09-12 10:15:13 +02:00
|
|
|
#[
|
2025-09-14 22:55:44 +02:00
|
|
|
Console items/text section using ImGuiTextSelect in a child window
|
2025-09-16 22:21:11 +02:00
|
|
|
Features:
|
|
|
|
|
- Horizontal+vertical scrolling,
|
|
|
|
|
- Autoscroll
|
|
|
|
|
- Colored text output
|
|
|
|
|
- Text highlighting, copy/paste
|
2025-09-14 22:55:44 +02:00
|
|
|
|
|
|
|
|
Problems I encountered with other approaches (Multi-line Text Input, TextEditor, ...):
|
2025-09-16 22:21:11 +02:00
|
|
|
- https://github.com/ocornut/imgui/issues/383#issuecomment-2080346129
|
|
|
|
|
- https://github.com/ocornut/imgui/issues/950
|
|
|
|
|
|
2025-09-14 22:55:44 +02:00
|
|
|
Huge thanks to @dinau for implementing ImGuiTextSelect into imguin very rapidly after I requested it.
|
2025-09-12 10:15:13 +02:00
|
|
|
]#
|
2025-09-16 20:17:48 +02:00
|
|
|
let consolePadding: float = 10.0f
|
2025-09-25 10:01:49 +02:00
|
|
|
let footerHeight = (consolePadding * 2) + (igGetStyle().ItemSpacing.y + igGetFrameHeightWithSpacing()) * 0.75f
|
2025-09-16 20:17:48 +02:00
|
|
|
let textSpacing = igGetStyle().ItemSpacing.x
|
|
|
|
|
|
|
|
|
|
# Padding
|
|
|
|
|
igDummy(vec2(0.0f, consolePadding))
|
2025-09-18 12:35:26 +02:00
|
|
|
|
2025-09-25 10:01:49 +02:00
|
|
|
#[
|
|
|
|
|
Session information
|
|
|
|
|
]#
|
|
|
|
|
let domain = if component.agent.domain.isEmptyOrWhitespace(): "" else: fmt".{component.agent.domain}"
|
2025-09-25 20:22:56 +02:00
|
|
|
let sessionInfo = fmt"{component.agent.username}@{component.agent.hostname}{domain} | {component.agent.ip} | {$component.agent.pid}/{component.agent.process}".cstring
|
2025-09-25 10:01:49 +02:00
|
|
|
igTextColored(GRAY, sessionInfo)
|
|
|
|
|
igSameLine(0.0f, 0.0f)
|
|
|
|
|
|
2025-09-18 12:35:26 +02:00
|
|
|
#[
|
|
|
|
|
Filter & Options
|
|
|
|
|
]#
|
2025-09-25 10:01:49 +02:00
|
|
|
|
|
|
|
|
var availableSize: ImVec2
|
|
|
|
|
igGetContentRegionAvail(addr availableSize)
|
2025-09-18 12:35:26 +02:00
|
|
|
var labelSize: ImVec2
|
|
|
|
|
igCalcTextSize(addr labelSize, ICON_FA_MAGNIFYING_GLASS, nil, false, 0.0f)
|
|
|
|
|
|
2025-09-25 10:01:49 +02:00
|
|
|
let searchBoxWidth: float32 = 200.0f
|
|
|
|
|
igSameLine(0.0f, availableSize.x - (labelSize.x + textSpacing) - searchBoxWidth)
|
|
|
|
|
|
|
|
|
|
# Show tooltip when hovering the search icon
|
2025-09-18 12:35:26 +02:00
|
|
|
igTextUnformatted(ICON_FA_MAGNIFYING_GLASS.cstring, nil)
|
|
|
|
|
if igIsItemHovered(ImGuiHoveredFlags_None.int32):
|
|
|
|
|
igBeginTooltip()
|
|
|
|
|
igText("Press CTRL+F to focus console filter.")
|
|
|
|
|
igText("Use \",\" as a delimiter to filter for multiple values.")
|
|
|
|
|
igText("Use \"-\" to exclude values.")
|
2025-09-27 15:18:45 +02:00
|
|
|
igText("Example: \"-warning,a,b\" returns all lines that do not include \"warning\" but include either \"a\" or \"b\".")
|
2025-09-18 12:35:26 +02:00
|
|
|
igEndTooltip()
|
|
|
|
|
|
|
|
|
|
if igIsWindowFocused(ImGui_FocusedFlags_ChildWindows.int32) and io.KeyCtrl and igIsKeyPressed_Bool(ImGuiKey_F, false):
|
|
|
|
|
igSetKeyboardFocusHere(0)
|
|
|
|
|
|
|
|
|
|
igSameLine(0.0f, textSpacing)
|
2025-09-25 10:01:49 +02:00
|
|
|
component.filter.ImGuiTextFilter_Draw("##ConsoleSearch", searchBoxWidth)
|
2025-09-18 12:35:26 +02:00
|
|
|
|
2025-09-16 20:17:48 +02:00
|
|
|
try:
|
|
|
|
|
# Set styles of the console window
|
|
|
|
|
igPushStyleColor_Vec4(ImGui_Col_FrameBg.int32, vec4(0.1f, 0.1f, 0.1f, 1.0f))
|
|
|
|
|
igPushStyleColor_Vec4(ImGui_Col_ScrollbarBg.int32, vec4(0.1f, 0.1f, 0.1f, 1.0f))
|
|
|
|
|
igPushStyleColor_Vec4(ImGui_Col_Border.int32, vec4(0.2f, 0.2f, 0.2f, 1.0f))
|
|
|
|
|
igPushStyleVar_Float(ImGui_StyleVar_FrameBorderSize .int32, 1.0f)
|
|
|
|
|
|
|
|
|
|
let childWindowFlags = ImGuiChildFlags_NavFlattened.int32 or ImGui_ChildFlags_Borders.int32 or ImGui_ChildFlags_AlwaysUseWindowPadding.int32 or ImGuiChildFlags_FrameStyle.int32
|
|
|
|
|
if igBeginChild_Str("##Console", vec2(-1.0f, -footerHeight), childWindowFlags, ImGuiWindowFlags_HorizontalScrollbar.int32):
|
|
|
|
|
# Display console items
|
2025-09-18 12:35:26 +02:00
|
|
|
for item in component.console.items:
|
|
|
|
|
|
|
|
|
|
# Apply filter
|
|
|
|
|
if component.filter.ImGuiTextFilter_IsActive():
|
|
|
|
|
if not component.filter.ImGuiTextFilter_PassFilter(item.getText(), nil):
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
item.print()
|
2025-09-16 20:17:48 +02:00
|
|
|
|
|
|
|
|
component.textSelect.textselect_update()
|
|
|
|
|
|
|
|
|
|
# Auto-scroll to bottom
|
|
|
|
|
if igGetScrollY() >= igGetScrollMaxY():
|
|
|
|
|
igSetScrollHereY(1.0f)
|
|
|
|
|
|
|
|
|
|
except IndexDefect:
|
|
|
|
|
# CTRL+A crashes when no items are in the console
|
|
|
|
|
discard
|
2025-09-14 22:55:44 +02:00
|
|
|
|
2025-09-16 20:17:48 +02:00
|
|
|
finally:
|
|
|
|
|
igPopStyleColor(3)
|
|
|
|
|
igPopStyleVar(1)
|
|
|
|
|
igEndChild()
|
2025-09-14 22:55:44 +02:00
|
|
|
|
2025-09-16 20:17:48 +02:00
|
|
|
# Padding
|
|
|
|
|
igDummy(vec2(0.0f, consolePadding))
|
2025-09-14 22:55:44 +02:00
|
|
|
|
2025-09-12 10:15:13 +02:00
|
|
|
#[
|
|
|
|
|
Input field with prompt indicator
|
2025-09-14 22:55:44 +02:00
|
|
|
]#
|
|
|
|
|
igText(fmt"[{component.agent.agentId}]")
|
2025-09-16 20:17:48 +02:00
|
|
|
igSameLine(0.0f, textSpacing)
|
2025-09-14 22:55:44 +02:00
|
|
|
|
|
|
|
|
# Calculate available width for input
|
2025-09-25 10:01:49 +02:00
|
|
|
igGetContentRegionAvail(addr availableSize)
|
|
|
|
|
igSetNextItemWidth(availableSize.x)
|
2025-09-14 22:55:44 +02:00
|
|
|
|
2025-09-16 22:21:11 +02:00
|
|
|
let inputFlags = ImGuiInputTextFlags_EnterReturnsTrue.int32 or ImGuiInputTextFlags_EscapeClearsAll.int32 or ImGuiInputTextFlags_CallbackHistory.int32 or ImGuiInputTextFlags_CallbackCompletion.int32
|
|
|
|
|
if igInputText("##Input", addr component.inputBuffer[0], MAX_INPUT_LENGTH, inputFlags, callback, cast[pointer](component)):
|
2025-09-16 20:17:48 +02:00
|
|
|
|
2025-09-22 21:53:13 +02:00
|
|
|
let command = ($(addr component.inputBuffer[0])).strip()
|
|
|
|
|
if not command.isEmptyOrWhitespace():
|
|
|
|
|
|
2025-09-27 15:34:01 +02:00
|
|
|
component.addItem(LOG_COMMAND, command)
|
2025-09-22 21:53:13 +02:00
|
|
|
|
2025-09-27 15:34:01 +02:00
|
|
|
# Send command to team server
|
2025-09-27 17:45:52 +02:00
|
|
|
ws.sendAgentCommand(component.agent.agentId, command)
|
2025-09-22 21:53:13 +02:00
|
|
|
|
|
|
|
|
# Add command to console history
|
|
|
|
|
component.history.add(command)
|
|
|
|
|
component.historyPosition = -1
|
2025-09-16 20:17:48 +02:00
|
|
|
|
|
|
|
|
zeroMem(addr component.inputBuffer[0], MAX_INPUT_LENGTH)
|
|
|
|
|
focusInput = true
|
2025-09-14 22:55:44 +02:00
|
|
|
|
2025-09-11 18:18:13 +02:00
|
|
|
igSetItemDefaultFocus()
|
2025-09-16 20:17:48 +02:00
|
|
|
if focusInput:
|
2025-09-25 10:01:49 +02:00
|
|
|
igSetKeyboardFocusHere(-1)
|