15 KiB
G-001 Pluggable console backends for non-detectable terminals
Status: achieved 2026-07-11 Scope: src/modules/opunk/console-999999.0a1.0.tm, src/modules/punk/console-999999.0a1.0.tm, src/modules/punk/repl-999999.0a1.0.tm, src/lib/app-punkshell/punkshell.tcl Goal: an interactive REPL can be launched against a non-detectable terminal-like device (ssh channel, tk text widget) via an ::opunk::Console subclass, with no edits to the base class or punk::console. Acceptance: a subshell started with an ssh-channel-backed and a tk-widget-backed ::opunk::Console subclass runs an interactive REPL that reads/writes through that console; size, at_eof, and can_respond are answered by the subclass overrides; the base ::opunk::Console and punk::console module are unchanged.
Context
The Punk REPL talks to its user through a console object. The base ::opunk::Console class (src/modules/opunk/console-999999.0a1.0.tm) represents a console as an in/out channel pair with settled capability facts, and is designed from the outset for subclassing: it is declared -virtual so that "channel environments that respond like terminals but aren't platform consoles can subclass (-extends ::opunk::Console) and override the capability/size methods, with existing holders of console values dispatching correctly." The size method comment names the example explicitly: "Non-channel subclasses (e.g a tk text widget acting as a terminal) override this method entirely."
Standard terminal detection (is_console_or_tty, lines 151-176 of opunk/console-999999.0a1.0.tm) uses twapi::GetConsoleMode on Windows, chan configure -inputmode/-mode cross-platform, and environment hints (MSYSTEM, TERM_PROGRAM) as a fallback for mintty-without-winpty. The class comment already flags the limitation: this is a heuristic that trades false positives (piped-but-open input treated as a terminal) for false negatives, and only works for devices that either expose channel mode flags or set those environment variables.
Terminal-like devices that don't expose any of those signals cannot be auto-detected. Two concrete cases motivate this goal:
- SSH-channel consoles. A Punk subshell reachable over an ssh connection presents a socket channel, not a platform tty. There is no
-inputmode/-mode, notwapihandle, and no inheritedMSYSTEM/TERM_PROGRAM. Standard detection returns 0, so ANSI cursor-report queries are suppressed and the REPL degrades to a default 80x24 with no capability settling. - Tk-widget consoles. A Tk text widget acting as a terminal is not a channel at all. The base
size/at_eof/is_console_or_tty/can_respondmethods are all channel-shaped; the class comment concedes these subclasses "override this method entirely - none of the channel/provider logic is imposed on them."
The seam for both already exists: punk::console::console_spec_resolve accepts an ::opunk::Console object value as a console spec, so a REPL can in principle be pointed at any subclass instance. What is missing is (a) the concrete subclasses and (b) the launch-time wiring that selects a non-default console for a given REPL/subshell.
Pluggable hooks already in place that backends may use or ignore:
::opunk::console::waiting_chunks_arrayvar— redirects probe-consumed bytes to a cooperative store so active readers see them. Channel-backed subclasses reusing the baseat_eofprobe benefit from this; non-channel subclasses ignore it.::opunk::console::size_query_provider— a command prefix registered bypunk::consoleto give the base class access to ANSI cursor-report mechanisms. Channel-backed subclasses gain this for free when the integrating layer registers it; non-channel subclasses overridesizeand never touch it.
Approach
Add two reference ::opunk::Console subclasses, each in its own module under src/modules/opunk/ following the -999999.0a1.0.tm naming convention:
opunk::console::ssh— wraps an ssh socket channel pair as a console. Overridesis_console_or_ttyto return 1 (the backend is constructed knowing the channel is a terminal),can_respondto settle to 1,at_eofto check the channel's eof without the base class's pipe-probe (the channel is a known terminal, not a probed pipe), andsizeto either delegate to the registeredsize_query_provider(ANSI cursor report over the ssh channel) or query a backend-specific size if the ssh transport exposes one. Reuseswaiting_chunks_arrayvarredirection if the probe path is exercised.opunk::console::tk— wraps a Tk text widget as a console. Overridessize(widget dimensions),at_eof(a backend-specific eof marker, not a channel eof),is_console_or_tty(returns 1), andcan_respond(returns 1). Does not touchwaiting_chunks_arrayvarorsize_query_provider— non-channel subclass.
Then add launch-time console selection so a REPL or subshell can be started against a non-default console without hardcoding stdin/stdout. The selection seam lives at REPL init (repl::init in src/modules/punk/repl-999999.0a1.0.tm, lines ~3109-3128) where the "default" anchored console instance is currently constructed. The -console convention already being migrated through punk::console (per src/modules/punk/AGENTS.md "Work Guidance") is the natural extension point: accept a console spec (channel pair, instance name, or object value) at repl::init and resolve it via punk::console::console_spec_resolve, settling the resulting object's capability as appropriate for the backend.
Alternatives considered
- Auto-detect ssh/tk consoles in
is_console_or_tty. Rejected: the class comment already explains detection is deliberately heuristic because non-detectable devices don't expose the signals detection relies on. Adding more env-var hints or channel-shape sniffing for ssh/tk would compound the false-positive risk the comment warns about. Subclassing with explicit construction-time capability is what the-virtualdesign is for. - Put the ssh/tk subclasses inside
punk::consolerather thanopunk. Rejected:punk::consoleis the integrating layer that registers providers with the base class; putting backends there would invert the dependency direction the class comment insists on ("the class depending on punk::console" is explicitly avoided). Backends belong inopunkalongside the base class, or in their own namespaces. - Make
sizepluggable per-instance instead of per-class. Rejected: thesize_query_providerhook already covers the per-instance pluggable case for channel-based consoles. Non-channel subclasses need full method override, which is the-virtualdispatch path. A second per-instance hook would duplicate the existing mechanism. - Detect tk widgets by checking for a Tk window path. Rejected: a Tk text widget is not a channel and shouldn't be forced through channel detection. The subclass is constructed knowing what it wraps; detection is the wrong tool.
Notes
- The
-virtualvoo dispatch means existing holders of anopunk::Consolevalue (e.g.punk::console::console_spec_resolvecallers, REPL init) dispatch to the subclass methods automatically once the value carries the subclass namespace tag. No call-site changes should be needed beyond passing the new console spec at launch. punk::console::ensure_object_integrationregisters the size provider and redirects the probe-byte store. Channel-backed ssh subclass inherits this; tk subclass ignores it. Confirm during implementation that the tk subclass'ssizeoverride does not accidentally fall through to the provider path.- The base class
at_eofdoes a non-blocking 1-byte probe on pipe-like channels and parks the byte inwaiting_chunks_waiting. The ssh subclass should overrideat_eofto usechan eofon the ssh channel directly — probing a socket would consume a byte the protocol layer may need. - The "first subshell asymmetry" TODO at
repl-999999.0a1.0.tm:3130-3132is G-002's concern, not G-001's, but G-001's launch-time console selection is a prerequisite for G-002's "target a named console" acceptance criterion. Sequence G-001 before G-002. - No persisted prior chat on this topic was found in project sessions; the motivation comes from the existing class design comments and the user's stated intent.
Progress (activated 2026-07-11)
Additional context since authoring: the G-044-detail repl characterization work (2026-07-11) identified this goal as the ENABLING refactor for repl/editbuf unit testing - class_editbuf is console-coupled and needs a deterministic console double. A third reference subclass (the test double) was therefore added ahead of ssh/tk in the build order.
Landed (increment 1)
Three backend modules under src/modules/opunk/console/, base ::opunk::Console and punk::console UNCHANGED (verified - no diffs):
opunk::console::test/ ::opunk::TestConsole - deterministic channel-pair double: fixed size (-columns/-rows at construction, stored in the inherited default-size field), is_console_or_tty/can_respond 1 (settled wins), at_eof = plain chan eof with NO probe (a pending byte is never consumed - pinned by test).opunk::console::ssh/ ::opunk::SshConsole - per the Approach: construction-time capability, chan-eof without probe, size via the registered size_query_provider. Flagship verification: with a scripted "remote terminal" answering CSI 6n over a socket pair, ::opunk::Console::size on the subclass value resolves 100x30 through punk::console's ANSI cursor-report provider QUERYING OVER THE SOCKET - the entire value proposition proven without a real ssh connection.opunk::console::tk/ ::opunk::TkConsole - widget path in the in/out slots, terminal_class tk-text, size from widget char dims, at_eof via backend marker (::opunk::console::tk::set_eof) or widget destruction; no Tk require at module load. Verified live under punk91 src (the tk-loading experiment kit): size/eof flag/clear/destroy all correct.
voo -extends findings recorded in src/modules/opunk/AGENTS.md: child classes inherit public accessors + field INDEX variables but not parent-private my.* accessors - subclass constructors initialise inherited private fields via the index variables; method bodies use the parent's public accessor methods.
Tests: src/tests/modules/opunk/console/testsuites/console/backends.test (8 tests; 7 green + tk env-gated on tcl 9.0.3 and 8.7; tk case verified under punk91).
Landed (increment 2, 2026-07-11) - acceptance met
Repl launch wiring (punk::repl 0.4.0, base ::opunk::Console and punk::console still UNCHANGED - verified via VCS status at the flip):
repl::init -console <spec>resolves any console_spec_resolve form and stores per-repl channel state (repl::conin/conout/conerr; conerr==conout for a foreign console - per-repl err channels are G-011).repl::start's inchan is now optional, defaulting to the selected console's input. All existing callers (always explicit stdin) unchanged.- Output routing: rputs maps stdout/stderr (and the debug pseudo-channels) per-repl; doprompt writes conerr and prompts unconditionally for a foreign console (a selected console is a terminal by declaration - process tcl_interactive no longer gates it).
- Code-interp output: for a foreign console the init_script (repltype punk/0) installs shellfilter 'var' JUNCTION stacks on the code interp's stdout/stderr (no pass-through to the process channels); the repl collects the pending vars after each runscript and emits them to the console via rputs. Interleaving between the two streams within a run is not preserved; emission is per-run (streaming is future work, see residue).
- Console-object dispatch: new repl::console_at_eof / console_get_size / console_is_default helpers - eof checks at the reader loop and the error traps, and size for the raw-mode width path, dispatch to the selected object's (possibly overridden) at_eof/size; can_respond gates the ansi_wanted==2 settle without probing. Process-console behaviours (stdin reopen on eof, raw-mode re-enable, mode line at exit, the windows utf-16be line re-decode experiment) now apply only to the default console.
- opunk::console::tk 0.2.0: TkConsole holds the widget in a subclass o_widget field (always the authority for size/eof/capability; new 'widget' accessor) and accepts -in/-out channels; new minimal wiring ::opunk::console::tk::console builds a reflected output channel rendering into the widget plus a Return-binding input pipe (submit/feed/teardown), so a channel-driven repl runs against the widget. Default widget-path construction unchanged (backends.test pins re-verified).
Acceptance verification (src/tests/modules/punk/repl/testsuites/repl/consolebackends.test, exec-driven child processes via src/tests/testsupport/repl_console_driver.tcl - a repl cannot run inside the shared runtests testinterp because codethread quit/exit callbacks thread::send to the thread's MAIN interp):
- ssh: ::opunk::SshConsole over a socket pair; the scripted remote terminal answers
CSI 6n (repl::console_get_size resolves 100x30 THROUGH THE SOCKET via punk::console's
provider, exercising the subclass size + can_respond overrides) and converses
prompt-by-prompt: results (7, 42), diverted code-interp stdout+stderr routed to the
socket, clean
exit 0completion. - tk: ::opunk::TkConsole over a wired text widget; lines "typed" via feed echo in the
widget, results and diverted output render into it, size from widget char dims
(100x30), at_eof from the widget marker/existence, clean
exit 0. (Runs un-gated: Tk loads only in the child process; self-gates when Tk is unavailable.) - test: ::opunk::TestConsole chan-pipe double - same conversation, the repl seam for future editbuf/repl characterization (G-044).
Residue / follow-on notes (non-gating)
- Foreign-console code-interp output is emitted per-run (batch): a long-running command's
incremental output and between-run background
afterwrites appear at the next run boundary. A streaming bridge (junction-to-pipe + fileevent forwarding) is the natural refinement - fits the G-002 subshell-comms work. - Junction stacks are wired for repltype punk/0 only; safe/safebase/punksafe subshell types on a foreign console would still write code-interp output to the process std channels.
- Line mode is the verified path; raw-mode/editbuf behaviour on a foreign console is untested (G-013 raw-default and G-044 completion work will exercise it; the width path already routes via console_get_size).
- ansi_wanted/colour_disabled and the raw-state tsv remain process-global (documented punk::console design); a foreign-console repl settling ansi_wanted affects the whole process - scoped console state is G-008.
- get_prompt_config still keys result/info prompt decoration on process tcl_interactive, so a foreign-console repl in a non-interactive process shows undecorated results (prompts themselves are emitted).