Wellp, I solved it using pdcurses.  In case someone else wants to do something similar, here's how I did it.  First, I initialize the console thusly:
Console::Console(bool makeConsole)
{
    if (makeConsole == false)
        return;
    if (self)
        throw ("You only need one console - do not make another!\n");
    self = this;
#ifdef WIN32
    AllocConsole();
#endif
    initscr();
    inputLine = newwin(1, COLS, LINES - 1, 0);
    outputLines = newwin(LINES - 1, COLS, 0, 0);
    if (has_colors())
    {
        start_color();
        for (int i = 1; i <= COLOR_WHITE; ++i)
        {
            init_pair(i, i, COLOR_BLACK);
        }
    }
    else
        wprintw(outputLines, "Terminal cannot print colors.\n");
    scrollok(outputLines, TRUE);
    scrollok(inputLine, TRUE);
    leaveok(inputLine, TRUE);
    nodelay(inputLine, TRUE);
    cbreak();
    noecho();
    keypad(inputLine, TRUE);
    initCommands();
    hello("Starting %s.\n", APP_NAME);
    hellomore("Version %i.%i.%i.\n\n", APP_MAJORVER, APP_MINORVER, APP_REVISION);
}
Next, This is the function responsible for handling output.  It's actually very simple, I don't need to do anything special to keep it thread-safe.  I might simply not have encountered any issues with it, but an easy fix would be to slap a mutex on it.
void Console::sendFormattedMsg(short prefixColor, const char* prefix, short color, const char* format, ...)
{
    if (!self)
        return;
    va_list args;
    va_start(args, format);
    if (has_colors())
    {
        if (prefix)
        {
            wattron(outputLines, A_BOLD | COLOR_PAIR(prefixColor));
            wprintw(outputLines, prefix);
        }
        if (color == COLOR_WHITE)
            wattroff(outputLines, A_BOLD);
        wattron(outputLines, COLOR_PAIR(color));
        vwprintw(outputLines, format, args);
        wattroff(outputLines, A_BOLD | COLOR_PAIR(color));
    }
    else
    {
        wprintw(outputLines, prefix);
        vwprintw(outputLines, format, args);
    }
    wrefresh(outputLines);
    va_end(args);
}
And finally, input.  This one required quite a bit of fine-tuning.
void Console::inputLoop(void)
{
    static string input;
    wattron(inputLine, A_BOLD | COLOR_PAIR(COLOR_WHITE));
    wprintw(inputLine, "\n> ");
    wattroff(inputLine, A_BOLD | COLOR_PAIR(COLOR_WHITE));
    wprintw(inputLine, input.c_str());
    wrefresh(inputLine);
    char c = wgetch(inputLine);
    if (c == ERR)
        return;
    switch (c)
    {
    case '\n':
        if (input.size() > 0)
        {
            sendFormattedMsg(COLOR_WHITE, "> ", COLOR_WHITE, input.c_str());
            cprint("\n");
            executeCommand(&input[0]);
            input.clear();
        }
        break;
    case 8:
    case 127:
        if (input.size() > 0) input.pop_back();
        break;
    default:
        input += c;
        break;
    }
}
This is run every frame from the same thread that handles window messages.  I disabled wgetch()'s blocking behavior using nodelay(), eliminating the need to have console input running in it's own thread. I also disable echoing and echo the input manually.  Enabling scrolling on the input window allows me to clear it's contents using a simple "\n", replacing it with updated contents if the user has typed anything.  It supports everything one would expect from a simple, multi-threaded terminal capable to typing input as well as receiving output from multiple threads.