Files
conquest/src/client/views/console.nim

285 lines
10 KiB
Nim
Raw Normal View History

import whisky
import strformat, strutils, times
import imguin/[cimgui, glfw_opengl, simple]
2025-09-18 12:35:26 +02:00
import ../utils/[appImGui, colors]
import ../../common/[types]
import ../websocket
2025-09-16 20:17:48 +02:00
const MAX_INPUT_LENGTH = 512
type
ConsoleComponent* = ref object of RootObj
agent*: UIAgent
showConsole*: bool
2025-09-16 20:17:48 +02:00
inputBuffer: array[MAX_INPUT_LENGTH, char]
console*: ConsoleItems
history: seq[string]
historyPosition: int
currentInput: string
textSelect: ptr TextSelect
2025-09-18 12:35:26 +02:00
filter: ptr ImGuiTextFilter
#[
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
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
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()
if not outLen.isNil:
outLen[] = line.len.csize_t
return line
proc Console*(agent: UIAgent): ConsoleComponent =
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
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("")
#[
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
]#
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(
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-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)
proc draw*(component: ConsoleComponent, ws: WebSocket) =
igBegin(fmt"[{component.agent.agentId}] {component.agent.username}@{component.agent.hostname}".cstring, addr component.showConsole, 0)
defer: igEnd()
2025-09-18 12:35:26 +02:00
let io = igGetIO()
2025-09-16 20:17:48 +02:00
var focusInput = false
#[
Console items/text section using ImGuiTextSelect in a child window
Features:
- Horizontal+vertical scrolling,
- Autoscroll
- Colored text output
- Text highlighting, copy/paste
Problems I encountered with other approaches (Multi-line Text Input, TextEditor, ...):
- https://github.com/ocornut/imgui/issues/383#issuecomment-2080346129
- https://github.com/ocornut/imgui/issues/950
Huge thanks to @dinau for implementing ImGuiTextSelect into imguin very rapidly after I requested it.
]#
2025-09-16 20:17:48 +02:00
let consolePadding: float = 10.0f
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
#[
Session information
]#
let domain = if component.agent.domain.isEmptyOrWhitespace(): "" else: fmt".{component.agent.domain}"
let sessionInfo = fmt"{component.agent.username}@{component.agent.hostname}{domain} | {component.agent.ip} | {$component.agent.pid}/{component.agent.process}".cstring
igTextColored(GRAY, sessionInfo)
igSameLine(0.0f, 0.0f)
2025-09-18 12:35:26 +02:00
#[
Filter & Options
]#
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)
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.")
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)
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-16 20:17:48 +02:00
finally:
igPopStyleColor(3)
igPopStyleVar(1)
igEndChild()
2025-09-16 20:17:48 +02:00
# Padding
igDummy(vec2(0.0f, consolePadding))
#[
Input field with prompt indicator
]#
igText(fmt"[{component.agent.agentId}]")
2025-09-16 20:17:48 +02:00
igSameLine(0.0f, textSpacing)
# Calculate available width for input
igGetContentRegionAvail(addr availableSize)
igSetNextItemWidth(availableSize.x)
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
let command = ($(addr component.inputBuffer[0])).strip()
if not command.isEmptyOrWhitespace():
component.addItem(LOG_COMMAND, command)
# Send command to team server
ws.sendAgentCommand(component.agent.agentId, command)
# 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
igSetItemDefaultFocus()
2025-09-16 20:17:48 +02:00
if focusInput:
igSetKeyboardFocusHere(-1)